@@ -0,0 +1 @@
|
||||
"""Браузерные E2E-тесты WESP."""
|
||||
@@ -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()
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Re-export dual-terminal helpers (fixtures live in tests/e2e/conftest.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.e2e.conftest import E2E_PERIOD_A, sync_both
|
||||
|
||||
DUAL_E2E_PERIOD_A = E2E_PERIOD_A
|
||||
|
||||
__all__ = ["DUAL_E2E_PERIOD_A", "sync_both"]
|
||||
@@ -0,0 +1,433 @@
|
||||
"""Общие хелперы Playwright E2E для /recipes (reorder, transfer, API assert)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import List, Literal, Optional, Tuple
|
||||
|
||||
from playwright.sync_api import Page
|
||||
|
||||
LONG_HOVER_MS = 1500
|
||||
|
||||
MoveDirection = Literal["up", "down"]
|
||||
|
||||
|
||||
def period_recipe_ids(page: Page, live_server_url: str, period_id: str) -> List[str]:
|
||||
resp = page.request.get(f"{live_server_url}/api/periods/{period_id}/recipes")
|
||||
assert resp.ok, resp.text()
|
||||
return [r["id"] for r in resp.json()]
|
||||
|
||||
|
||||
def assert_period_order(
|
||||
page: Page, live_server_url: str, period_id: str, expected: List[str]
|
||||
) -> None:
|
||||
actual = period_recipe_ids(page, live_server_url, period_id)
|
||||
assert actual == expected, f"period {period_id}: API order {actual!r} != {expected!r}"
|
||||
|
||||
|
||||
def _api_move_recipe_in_period(
|
||||
page: Page,
|
||||
live_server_url: str,
|
||||
recipe_id: str,
|
||||
period_id: str,
|
||||
from_index: int,
|
||||
to_index: int,
|
||||
) -> None:
|
||||
resp = page.request.put(
|
||||
f"{live_server_url}/api/recipes/{recipe_id}/move",
|
||||
data=json.dumps(
|
||||
{"period_id": period_id, "from_index": from_index, "to_index": to_index}
|
||||
),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.ok, resp.text()
|
||||
|
||||
|
||||
def ensure_two_recipe_order(
|
||||
page: Page,
|
||||
live_server_url: str,
|
||||
period_id: str,
|
||||
expected: List[str],
|
||||
) -> None:
|
||||
"""Привести порядок двух рейсов к expected (API move — стабильнее после modal/mobile UI)."""
|
||||
if len(expected) != 2:
|
||||
return
|
||||
actual = period_recipe_ids(page, live_server_url, period_id)
|
||||
if actual == expected:
|
||||
return
|
||||
first_id, second_id = expected
|
||||
if actual == [second_id, first_id]:
|
||||
_api_move_recipe_in_period(
|
||||
page, live_server_url, first_id, period_id, from_index=1, to_index=0
|
||||
)
|
||||
wait_period_order(page, live_server_url, period_id, expected, timeout=5000)
|
||||
elif actual == [first_id, second_id] and expected == [second_id, first_id]:
|
||||
_api_move_recipe_in_period(
|
||||
page, live_server_url, first_id, period_id, from_index=0, to_index=1
|
||||
)
|
||||
wait_period_order(page, live_server_url, period_id, expected, timeout=5000)
|
||||
|
||||
|
||||
def restore_two_recipe_order_if_reversed(
|
||||
page: Page,
|
||||
live_server_url: str,
|
||||
period_id: str,
|
||||
first_id: str,
|
||||
second_id: str,
|
||||
) -> None:
|
||||
"""После mobile/modal UI порядок иногда сбрасывается — вернуть [first, second]."""
|
||||
ensure_two_recipe_order(
|
||||
page, live_server_url, period_id, [first_id, second_id]
|
||||
)
|
||||
|
||||
|
||||
def wait_period_order(
|
||||
page: Page,
|
||||
live_server_url: str,
|
||||
period_id: str,
|
||||
expected: List[str],
|
||||
*,
|
||||
timeout: int = 8000,
|
||||
) -> None:
|
||||
page.wait_for_function(
|
||||
"""async (args) => {
|
||||
const [base, pid, expected] = args;
|
||||
const r = await fetch(base + '/api/periods/' + pid + '/recipes');
|
||||
if (!r.ok) return false;
|
||||
const ids = (await r.json()).map((x) => x.id);
|
||||
if (ids.length !== expected.length) return false;
|
||||
return ids.every((id, i) => id === expected[i]);
|
||||
}""",
|
||||
arg=[live_server_url, period_id, expected],
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def fetch_recipe(page: Page, live_server_url: str, recipe_id: str) -> dict:
|
||||
resp = page.request.get(f"{live_server_url}/api/recipes/{recipe_id}")
|
||||
assert resp.ok, resp.text()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def recipe_ingredient_snapshot(
|
||||
page: Page, live_server_url: str, recipe_id: str
|
||||
) -> List[Tuple[Optional[str], float]]:
|
||||
data = fetch_recipe(page, live_server_url, recipe_id)
|
||||
rows: List[Tuple[Optional[str], float]] = []
|
||||
for ing in data.get("ingredients") or []:
|
||||
w = ing.get("weightPerHead", ing.get("weight_per_head", 0))
|
||||
rows.append((ing.get("id"), float(w or 0)))
|
||||
return rows
|
||||
|
||||
|
||||
def list_dom_recipe_ids(page: Page) -> List[str]:
|
||||
items = page.locator("#recipesList .list-item[data-recipe-id]")
|
||||
return [items.nth(i).get_attribute("data-recipe-id") or "" for i in range(items.count())]
|
||||
|
||||
|
||||
def wait_editor_closed(page: Page, *, timeout: int = 10000) -> None:
|
||||
page.wait_for_selector("#recipeEdit", state="hidden", timeout=timeout)
|
||||
|
||||
|
||||
def wait_editor_open(page: Page, *, timeout: int = 15000) -> None:
|
||||
page.wait_for_selector("#recipeEdit", state="visible", timeout=timeout)
|
||||
|
||||
|
||||
def wait_mobile_dashboard_step(page: Page, step: str, *, timeout: int = 10000) -> None:
|
||||
page.wait_for_function(
|
||||
f"() => document.body.classList.contains('dashboard-mobile-step-{step}')",
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def select_period(
|
||||
page: Page,
|
||||
period_id: str,
|
||||
*,
|
||||
wait_recipe_id: str | None = None,
|
||||
live_server_url: str | None = None,
|
||||
expected_order: List[str] | None = None,
|
||||
) -> None:
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "GET" and f"/api/periods/{period_id}/recipes" in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator(f'[data-action="select-period"][data-period-id="{period_id}"]').click()
|
||||
if wait_recipe_id:
|
||||
page.wait_for_selector(
|
||||
f'#recipesList .list-item[data-recipe-id="{wait_recipe_id}"]', timeout=10000
|
||||
)
|
||||
if live_server_url and expected_order is not None:
|
||||
wait_period_order(page, live_server_url, period_id, expected_order)
|
||||
|
||||
|
||||
def click_move_recipe(page: Page, recipe_id: str, direction: MoveDirection) -> None:
|
||||
action = "move-recipe-up" if direction == "up" else "move-recipe-down"
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "PUT" and "/move" in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator(f'[data-action="{action}"][data-recipe-id="{recipe_id}"]').click()
|
||||
|
||||
|
||||
def drag_reorder_recipe(
|
||||
page: Page,
|
||||
recipe_id: str,
|
||||
*,
|
||||
direction: MoveDirection = "down",
|
||||
live_server_url: str | None = None,
|
||||
period_id: str | None = None,
|
||||
expected_order: List[str] | None = None,
|
||||
expect_move_response: bool = True,
|
||||
) -> None:
|
||||
list_root = page.locator("#recipesList")
|
||||
list_box = list_root.bounding_box()
|
||||
handle = page.locator(
|
||||
f'#recipesList .list-item[data-recipe-id="{recipe_id}"] [data-action="recipe-drag-handle"]'
|
||||
).first
|
||||
box = handle.bounding_box()
|
||||
assert box and list_box, f"handle/list for {recipe_id}"
|
||||
|
||||
cx = box["x"] + box["width"] / 2
|
||||
cy = box["y"] + 5
|
||||
page.mouse.move(cx, cy)
|
||||
page.mouse.down()
|
||||
page.mouse.move(list_box["x"] + list_box["width"] / 2, cy + 40, steps=8)
|
||||
|
||||
items = page.locator("#recipesList .list-item[data-recipe-id]")
|
||||
target = items.nth(1 if direction == "down" else 0)
|
||||
tbox = target.bounding_box()
|
||||
assert tbox
|
||||
drop_y = tbox["y"] + (8 if direction == "up" else tbox["height"] - 8)
|
||||
|
||||
def drop() -> None:
|
||||
page.mouse.move(tbox["x"] + tbox["width"] / 2, drop_y, steps=12)
|
||||
page.mouse.up()
|
||||
|
||||
if expect_move_response:
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "PUT" and "/move" in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
drop()
|
||||
else:
|
||||
drop()
|
||||
page.wait_for_timeout(400)
|
||||
|
||||
if live_server_url and period_id and expected_order is not None:
|
||||
wait_period_order(page, live_server_url, period_id, expected_order)
|
||||
|
||||
|
||||
def pointer_drag_reorder_recipe(
|
||||
page: Page,
|
||||
recipe_id: str,
|
||||
*,
|
||||
direction: MoveDirection = "down",
|
||||
) -> dict:
|
||||
"""Pointer-path reorder (не Playwright mouse/HTML5). Ловит cancel при recipe-reorder-active."""
|
||||
return page.evaluate(
|
||||
"""async ({ recipeId, direction }) => {
|
||||
const listRoot = document.getElementById('recipesList');
|
||||
const item = listRoot?.querySelector(
|
||||
`.list-item[data-recipe-id="${recipeId}"]`
|
||||
);
|
||||
const handle = item?.querySelector('[data-action="recipe-drag-handle"]');
|
||||
if (!handle || !item) return { ok: false, reason: 'no-handle' };
|
||||
|
||||
const items = [...listRoot.querySelectorAll(':scope > .list-item[data-recipe-id]')];
|
||||
const fromIdx = items.indexOf(item);
|
||||
const targetIdx = direction === 'down' ? fromIdx + 1 : fromIdx - 1;
|
||||
if (targetIdx < 0 || targetIdx >= items.length) {
|
||||
return { ok: false, reason: 'bad-target' };
|
||||
}
|
||||
const target = items[targetIdx];
|
||||
const handleRect = handle.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const cx = handleRect.left + handleRect.width / 2;
|
||||
const cy = handleRect.top + 5;
|
||||
const tx = targetRect.left + targetRect.width / 2;
|
||||
const ty = targetRect.top + targetRect.height - 8;
|
||||
|
||||
const pe = (type, x, y, buttons) =>
|
||||
new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
pointerId: 42,
|
||||
pointerType: 'mouse',
|
||||
button: 0,
|
||||
buttons,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const idsBefore = items.map((el) => el.dataset.recipeId);
|
||||
handle.dispatchEvent(pe('pointerdown', cx, cy, 1));
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
document.dispatchEvent(pe('pointermove', cx, cy + 50, 1));
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
|
||||
const reorderActive = listRoot.classList.contains('recipe-list-reorder-active');
|
||||
document.dispatchEvent(pe('pointerup', tx, ty, 0));
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
const idsAfter = [...listRoot.querySelectorAll(':scope > .list-item[data-recipe-id]')]
|
||||
.map((el) => el.dataset.recipeId);
|
||||
return { ok: true, reorderActive, idsBefore, idsAfter };
|
||||
}""",
|
||||
{"recipeId": recipe_id, "direction": direction},
|
||||
)
|
||||
|
||||
|
||||
def drag_transfer_to_period(
|
||||
page: Page,
|
||||
recipe_id: str,
|
||||
target_period_id: str,
|
||||
*,
|
||||
slot_index: int | None = None,
|
||||
live_server_url: str | None = None,
|
||||
source_period_id: str | None = None,
|
||||
target_period_id_for_assert: str | None = None,
|
||||
expected_source_order: List[str] | None = None,
|
||||
expected_target_order: List[str] | None = None,
|
||||
) -> None:
|
||||
handle = page.locator(
|
||||
f'#recipesList .list-item[data-recipe-id="{recipe_id}"] [data-action="recipe-drag-handle"]'
|
||||
).first
|
||||
period = page.locator(f'[data-action="select-period"][data-period-id="{target_period_id}"]')
|
||||
handle_box = handle.bounding_box()
|
||||
period_box = period.bounding_box()
|
||||
assert handle_box and period_box
|
||||
|
||||
page.mouse.move(handle_box["x"] + handle_box["width"] / 2, handle_box["y"] + 5)
|
||||
page.mouse.down()
|
||||
page.mouse.move(
|
||||
period_box["x"] + period_box["width"] / 2,
|
||||
period_box["y"] + 10,
|
||||
steps=12,
|
||||
)
|
||||
page.wait_for_timeout(LONG_HOVER_MS)
|
||||
page.mouse.up()
|
||||
|
||||
page.wait_for_selector("button.recipe-transfer-slot", timeout=10000)
|
||||
if slot_index is None:
|
||||
slot = page.locator("button.recipe-transfer-slot").first
|
||||
else:
|
||||
slot = page.locator(f'button.recipe-transfer-slot[data-insert-index="{slot_index}"]')
|
||||
with page.expect_response(
|
||||
lambda r: "/transfer" in r.url and r.request.method == "POST",
|
||||
timeout=15000,
|
||||
):
|
||||
slot.click()
|
||||
|
||||
if live_server_url:
|
||||
if source_period_id is not None and expected_source_order is not None:
|
||||
wait_period_order(page, live_server_url, source_period_id, expected_source_order)
|
||||
tgt = target_period_id_for_assert or target_period_id
|
||||
if expected_target_order is not None:
|
||||
wait_period_order(page, live_server_url, tgt, expected_target_order)
|
||||
|
||||
|
||||
def open_dispenser_period(
|
||||
page: Page,
|
||||
period_id: str,
|
||||
*,
|
||||
wait_recipe_id: str | None = None,
|
||||
live_server_url: str | None = None,
|
||||
expected_order: List[str] | None = None,
|
||||
) -> None:
|
||||
from tests.e2e.conftest import E2E_DISP_ID
|
||||
|
||||
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_DISP_ID}"]').click()
|
||||
select_period(
|
||||
page,
|
||||
period_id,
|
||||
wait_recipe_id=wait_recipe_id,
|
||||
live_server_url=live_server_url,
|
||||
expected_order=expected_order,
|
||||
)
|
||||
|
||||
|
||||
def click_copy_recipe(page: Page, recipe_id: str) -> None:
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "GET" and f"/api/recipes/{recipe_id}" in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator(f'[data-action="copy-recipe"][data-recipe-id="{recipe_id}"]').first.click()
|
||||
|
||||
|
||||
def fill_min_unloading_group(page: Page, *, name: str = "Группа", value: str = "100") -> None:
|
||||
rows = page.locator("#unloadingGroupsTableBody .unloading-group-row")
|
||||
before = rows.count()
|
||||
if before == 0:
|
||||
page.locator('[data-action="add-unloading-group"]').click()
|
||||
page.wait_for_function(
|
||||
f"() => document.querySelectorAll('#unloadingGroupsTableBody .unloading-group-row').length > {before}"
|
||||
)
|
||||
row = page.locator("#unloadingGroupsTableBody .unloading-group-row").last
|
||||
row.locator("input.group-name").fill(name)
|
||||
row.locator("input.group-value").fill(value)
|
||||
|
||||
|
||||
def create_recipe_in_period(
|
||||
page: Page,
|
||||
period_id: str,
|
||||
name: str,
|
||||
*,
|
||||
live_server_url: str | None = None,
|
||||
) -> str:
|
||||
page.locator(f'[data-action="create-recipe"][data-period-id="{period_id}"]').first.click()
|
||||
wait_editor_open(page)
|
||||
page.locator("#recipeName").fill(name)
|
||||
fill_min_unloading_group(page, name=f"{name} G", value="100")
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "POST" and f"/api/periods/{period_id}/recipes" in r.url,
|
||||
timeout=20000,
|
||||
) as resp:
|
||||
page.locator('[data-action="save-and-exit"]').click()
|
||||
recipe_id = resp.value.json().get("id")
|
||||
assert recipe_id
|
||||
wait_editor_closed(page)
|
||||
if live_server_url:
|
||||
wait_period_order(
|
||||
page,
|
||||
live_server_url,
|
||||
period_id,
|
||||
period_recipe_ids(page, live_server_url, period_id),
|
||||
)
|
||||
assert recipe_id in period_recipe_ids(page, live_server_url, period_id)
|
||||
return recipe_id
|
||||
|
||||
|
||||
def delete_recipe_unlink(
|
||||
page: Page,
|
||||
recipe_id: str,
|
||||
*,
|
||||
live_server_url: str,
|
||||
period_id: str,
|
||||
expected_period_order: List[str],
|
||||
) -> None:
|
||||
page.on("dialog", lambda d: d.accept())
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "DELETE" and recipe_id in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator(f'[data-action="delete-recipe"][data-recipe-id="{recipe_id}"]').click()
|
||||
wait_period_order(page, live_server_url, period_id, expected_period_order)
|
||||
|
||||
|
||||
def abort_transfer_via_escape(page: Page, recipe_id: str) -> None:
|
||||
handle = page.locator(
|
||||
f'#recipesList .list-item[data-recipe-id="{recipe_id}"] [data-action="recipe-drag-handle"]'
|
||||
).first
|
||||
list_root = page.locator("#recipesList")
|
||||
box = handle.bounding_box()
|
||||
list_box = list_root.bounding_box()
|
||||
assert box and list_box
|
||||
page.mouse.move(box["x"] + 5, box["y"] + 5)
|
||||
page.mouse.down()
|
||||
# Уводим курсор вправо за список — не пересекаем другие рейсы (иначе сработает reorder).
|
||||
page.mouse.move(list_box["x"] + list_box["width"] + 80, box["y"] + 5, steps=8)
|
||||
page.keyboard.press("Escape")
|
||||
page.mouse.up()
|
||||
assert page.locator("button.recipe-transfer-slot").count() == 0
|
||||
@@ -0,0 +1,14 @@
|
||||
"""E2E smoke: /components."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
def test_components_page_loads(zootech_authenticated_page, live_server_url) -> None:
|
||||
page = zootech_authenticated_page
|
||||
page.goto(f"{live_server_url}/components")
|
||||
page.wait_for_load_state("domcontentloaded")
|
||||
assert "components" in page.url or page.locator("body").is_visible()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""E2E: изменение плана на день → sync → оператор видит план (не мастер)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db
|
||||
from app.models import Recipe
|
||||
from app.services.daily_plan.replacements import replace_ingredient
|
||||
from app.services.daily_plan.skips import skip_ingredient
|
||||
from app.services.setup_state import mark_setup_complete
|
||||
from datetime import date
|
||||
|
||||
from tests.helpers.daily_plan_operator_helpers import (
|
||||
assert_operator_matches_plan_not_master,
|
||||
plan_recipe_json,
|
||||
)
|
||||
from tests.helpers.dispenser_recipe_fixtures import (
|
||||
E2E_DISP_COMP,
|
||||
E2E_DISP_RECIPE_1,
|
||||
seed_dispenser_period_recipes,
|
||||
)
|
||||
from tests.helpers.sync_dual_harness import SyncDualInstanceHarness
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def daily_plan_sync_harness() -> SyncDualInstanceHarness:
|
||||
from app.models import Component, Ingredient
|
||||
|
||||
harness = SyncDualInstanceHarness()
|
||||
harness.start()
|
||||
with harness.server_ctx():
|
||||
mark_setup_complete(harness.server_app)
|
||||
seed_dispenser_period_recipes()
|
||||
db.session.add(
|
||||
Ingredient(
|
||||
id="e2e-disp-ing-extra",
|
||||
name="Ing Extra",
|
||||
weight_per_head=3.0,
|
||||
amount=30.0,
|
||||
dry_matter=55.0,
|
||||
component_id=E2E_DISP_COMP,
|
||||
order=2,
|
||||
recipe_id=E2E_DISP_RECIPE_1,
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
)
|
||||
db.session.add(
|
||||
Component(
|
||||
id="e2e-disp-comp-e2e-alt",
|
||||
name="E2E Alt Silo",
|
||||
type="silage",
|
||||
dry_matter=35.0,
|
||||
protein=3.0,
|
||||
energy=6.0,
|
||||
price=0.0,
|
||||
)
|
||||
)
|
||||
recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1)
|
||||
recipe.dry_matter_locked = True
|
||||
db.session.commit()
|
||||
harness.drain_sync()
|
||||
yield harness
|
||||
harness.stop()
|
||||
|
||||
|
||||
def test_e2e_replacement_sync_operator_not_master(daily_plan_sync_harness) -> None:
|
||||
harness = daily_plan_sync_harness
|
||||
plan_date = date.today().isoformat()
|
||||
with harness.server_ctx():
|
||||
replace_ingredient(
|
||||
E2E_DISP_RECIPE_1,
|
||||
"e2e-disp-ing-1",
|
||||
"e2e-disp-comp-e2e-alt",
|
||||
plan_date,
|
||||
)
|
||||
harness.drain_sync()
|
||||
|
||||
kiosk = harness.term_a_app.test_client()
|
||||
with harness.terminal_ctx("a"):
|
||||
client_recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1)
|
||||
client_recipe.dry_matter_locked = True
|
||||
db.session.commit()
|
||||
|
||||
assert_operator_matches_plan_not_master(kiosk, E2E_DISP_RECIPE_1, plan_date=plan_date)
|
||||
|
||||
|
||||
def test_e2e_ingredient_skip_sync_operator_second_component(daily_plan_sync_harness) -> None:
|
||||
harness = daily_plan_sync_harness
|
||||
plan_date = date.today().isoformat()
|
||||
with harness.server_ctx():
|
||||
skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", plan_date)
|
||||
harness.drain_sync()
|
||||
|
||||
kiosk = harness.term_b_app.test_client()
|
||||
plan = plan_recipe_json(kiosk, E2E_DISP_RECIPE_1, plan_date=plan_date)
|
||||
assert len(plan["ingredients"]) == 1
|
||||
assert plan["ingredients"][0]["id"] == "e2e-disp-ing-extra"
|
||||
|
||||
operator = assert_operator_matches_plan_not_master(
|
||||
kiosk,
|
||||
E2E_DISP_RECIPE_1,
|
||||
plan_date=plan_date,
|
||||
expect_plan_differs_from_master=True,
|
||||
)
|
||||
assert operator["operator"]["total_component"] == int(round(operator["plan_amounts"][0]))
|
||||
|
||||
|
||||
def test_e2e_zootech_api_change_reaches_terminal_operator(daily_plan_sync_harness) -> None:
|
||||
"""Зоотех на сервере POST /api/daily-plan → sync → терминал оператора."""
|
||||
harness = daily_plan_sync_harness
|
||||
plan_date = date.today().isoformat()
|
||||
server = harness.server_test_client()
|
||||
resp = server.post(
|
||||
"/api/daily-plan/replacements/ingredients",
|
||||
json={
|
||||
"recipeId": E2E_DISP_RECIPE_1,
|
||||
"ingredientId": "e2e-disp-ing-1",
|
||||
"replacementComponentId": "e2e-disp-comp-e2e-alt",
|
||||
"date": plan_date,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
harness.drain_sync()
|
||||
|
||||
kiosk = harness.term_a_app.test_client()
|
||||
with harness.terminal_ctx("a"):
|
||||
client_recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1)
|
||||
client_recipe.dry_matter_locked = True
|
||||
db.session.commit()
|
||||
|
||||
assert_operator_matches_plan_not_master(kiosk, E2E_DISP_RECIPE_1, plan_date=plan_date)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""E2E smoke: /feed_consumption."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
def test_feed_consumption_page_loads(zootech_authenticated_page, live_server_url) -> None:
|
||||
page = zootech_authenticated_page
|
||||
page.goto(f"{live_server_url}/feed_consumption")
|
||||
page.wait_for_load_state("domcontentloaded")
|
||||
assert page.locator("body").is_visible()
|
||||
@@ -0,0 +1,14 @@
|
||||
"""E2E smoke: /feed_dispensers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
def test_feed_dispensers_page_loads(zootech_authenticated_page, live_server_url) -> None:
|
||||
page = zootech_authenticated_page
|
||||
page.goto(f"{live_server_url}/feed_dispensers")
|
||||
page.wait_for_load_state("networkidle")
|
||||
assert page.locator("body").is_visible()
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Playwright E2E: кормораздатчик — dashboard, copy/paste, unlink delete, move."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.e2e.conftest import (
|
||||
E2E_DISP_ID,
|
||||
E2E_DISP_RECIPE_1,
|
||||
E2E_DISP_RECIPE_2,
|
||||
E2E_PERIOD_A,
|
||||
E2E_PERIOD_B,
|
||||
)
|
||||
from tests.e2e.recipes_e2e_helpers import (
|
||||
drag_reorder_recipe,
|
||||
fetch_recipe,
|
||||
list_dom_recipe_ids,
|
||||
period_recipe_ids,
|
||||
pointer_drag_reorder_recipe,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
SCENARIO_E2E_DRAG_REORDER = "E2E_DRAG_REORDER"
|
||||
SCENARIO_E2E_MOVE_RECIPE_UP = "E2E_MOVE_RECIPE_UP"
|
||||
SCENARIO_E2E_EDITOR_UNLOADING_GROUP = "E2E_EDITOR_UNLOADING_GROUP"
|
||||
SCENARIO_E2E_EDITOR_CANCEL = "E2E_EDITOR_CANCEL"
|
||||
|
||||
|
||||
def test_dispenser_dashboard_three_columns(authenticated_page) -> None:
|
||||
page = authenticated_page
|
||||
assert page.locator("#dispensersCol").is_visible()
|
||||
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
|
||||
)
|
||||
assert page.locator("#periodsCol").is_visible()
|
||||
assert page.locator("#recipesCol").count() == 1
|
||||
|
||||
|
||||
def test_select_period_shows_recipes(two_periods_setup) -> None:
|
||||
page = two_periods_setup
|
||||
assert page.locator(
|
||||
f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]'
|
||||
).is_visible()
|
||||
assert page.locator(
|
||||
f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_2}"]'
|
||||
).is_visible()
|
||||
|
||||
|
||||
def test_create_recipe_opens_editor(two_periods_setup) -> None:
|
||||
page = two_periods_setup
|
||||
create_btn = page.locator('[data-action="create-recipe"]').first
|
||||
create_btn.click()
|
||||
page.wait_for_selector("#recipeEdit", state="visible", timeout=15000)
|
||||
assert page.locator("#recipeName").is_visible()
|
||||
|
||||
|
||||
def test_copy_paste_recipe_between_periods(two_periods_setup, live_server_url) -> None:
|
||||
page = two_periods_setup
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "GET" and f"/api/recipes/{E2E_DISP_RECIPE_1}" in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator(
|
||||
f'[data-action="copy-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]'
|
||||
).first.click()
|
||||
page.locator(f'[data-action="select-period"][data-period-id="{E2E_PERIOD_B}"]').click()
|
||||
paste = page.locator(
|
||||
f'[data-action="paste-recipe"][data-period-id="{E2E_PERIOD_B}"]'
|
||||
)
|
||||
page.wait_for_timeout(400)
|
||||
assert not paste.is_disabled(), "paste должен быть активен после copy"
|
||||
paste.click()
|
||||
page.wait_for_selector("#recipeEdit", state="visible", timeout=15000)
|
||||
assert "(копия)" in page.locator("#recipeName").input_value()
|
||||
group_rows = page.locator("#unloadingGroupsTableBody .unloading-group-row")
|
||||
page.wait_for_timeout(500)
|
||||
assert group_rows.count() >= 1, "после paste должна остаться хотя бы одна группа выгрузки"
|
||||
|
||||
page.locator('[data-action="save-and-exit"]').click()
|
||||
page.wait_for_selector("#recipeEdit", state="hidden", timeout=15000)
|
||||
|
||||
listed = page.request.get(f"{live_server_url}/api/periods/{E2E_PERIOD_B}/recipes")
|
||||
assert listed.ok
|
||||
new_ids = [r["id"] for r in listed.json() if r["id"] != E2E_DISP_RECIPE_1]
|
||||
assert new_ids
|
||||
pasted = fetch_recipe(page, live_server_url, new_ids[0])
|
||||
groups = pasted.get("unloading_groups") or []
|
||||
assert groups
|
||||
assert groups[0].get("distribution_type") in ("heads", "percent")
|
||||
assert float(groups[0].get("value") or 0) > 0
|
||||
|
||||
|
||||
def test_heads_change_recalculates_group_weights(dispenser_recipe_open) -> None:
|
||||
SCENARIO_E2E_HEADS_RECALC = "E2E_HEADS_RECALC" # noqa: F841
|
||||
page = dispenser_recipe_open
|
||||
type_select = page.locator(
|
||||
"#unloadingGroupsTableBody select[data-action='group-type-change']"
|
||||
).first
|
||||
if type_select.count():
|
||||
type_select.select_option("heads")
|
||||
page.wait_for_timeout(400)
|
||||
weight_input = page.locator("#unloadingGroupsTableBody .group-weight").first
|
||||
page.wait_for_timeout(800)
|
||||
before = float(weight_input.input_value() or 0)
|
||||
with page.expect_response(
|
||||
lambda r: "/api/recipes/calculate" in r.url and r.request.method == "POST",
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator("#headsCount").fill("5")
|
||||
page.locator("#headsCount").dispatch_event("input")
|
||||
page.locator("#headsCount").dispatch_event("change")
|
||||
page.wait_for_timeout(500)
|
||||
after = float(weight_input.input_value() or 0)
|
||||
assert before > 0
|
||||
assert after != before
|
||||
|
||||
|
||||
def test_move_recipe_down_changes_dom_order(two_periods_setup) -> None:
|
||||
page = two_periods_setup
|
||||
items = page.locator("#recipesList .list-item[data-recipe-id]")
|
||||
before = [items.nth(i).get_attribute("data-recipe-id") for i in range(items.count())]
|
||||
assert before == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
|
||||
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "PUT" and "/move" in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator(
|
||||
f'[data-action="move-recipe-down"][data-recipe-id="{E2E_DISP_RECIPE_1}"]'
|
||||
).click()
|
||||
page.wait_for_timeout(600)
|
||||
after = [items.nth(i).get_attribute("data-recipe-id") for i in range(items.count())]
|
||||
assert after == [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
|
||||
|
||||
|
||||
def test_dispenser_editor_shows_ingredients(dispenser_recipe_open) -> None:
|
||||
page = dispenser_recipe_open
|
||||
rows = page.locator("#ingredientsTableBody .ingredient-row")
|
||||
assert rows.count() >= 1
|
||||
|
||||
|
||||
def test_move_recipe_up_button_present(two_periods_setup) -> None:
|
||||
page = two_periods_setup
|
||||
assert page.locator('[data-action="move-recipe-up"]').count() >= 1
|
||||
assert page.locator('[data-action="move-recipe-down"]').count() >= 1
|
||||
|
||||
|
||||
def test_recipe_drag_handle_present(two_periods_setup) -> None:
|
||||
page = two_periods_setup
|
||||
assert page.locator('[data-action="recipe-drag-handle"]').count() >= 1
|
||||
|
||||
|
||||
def test_delete_recipe_unlinks_from_period(two_periods_setup, live_server_url) -> None:
|
||||
page = two_periods_setup
|
||||
page.on("dialog", lambda d: d.accept())
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "DELETE"
|
||||
and f"/periods/{E2E_PERIOD_A}/recipes/" in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator(
|
||||
f'[data-action="delete-recipe"][data-recipe-id="{E2E_DISP_RECIPE_2}"]'
|
||||
).click()
|
||||
page.wait_for_timeout(500)
|
||||
assert (
|
||||
page.locator(
|
||||
f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_2}"]'
|
||||
).count()
|
||||
== 0
|
||||
)
|
||||
resp = page.request.get(f"{live_server_url}/api/recipes/{E2E_DISP_RECIPE_2}")
|
||||
assert resp.ok
|
||||
|
||||
|
||||
def test_drag_reorder_swaps_recipes_and_api_order(two_periods_setup, live_server_url) -> None:
|
||||
SCENARIO_E2E_DRAG_REORDER # noqa: F841
|
||||
page = two_periods_setup
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
|
||||
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "PUT" and "/move" in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
drag_reorder_recipe(page, E2E_DISP_RECIPE_1, direction="down")
|
||||
page.wait_for_timeout(500)
|
||||
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
|
||||
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_A) == [
|
||||
E2E_DISP_RECIPE_2,
|
||||
E2E_DISP_RECIPE_1,
|
||||
]
|
||||
|
||||
|
||||
def test_mouse_drag_reorder_works_twice_in_a_row(two_periods_setup) -> None:
|
||||
page = two_periods_setup
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
|
||||
|
||||
drag_reorder_recipe(page, E2E_DISP_RECIPE_1, direction="down")
|
||||
page.wait_for_timeout(400)
|
||||
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
|
||||
|
||||
drag_reorder_recipe(
|
||||
page, E2E_DISP_RECIPE_1, direction="up", expect_move_response=False
|
||||
)
|
||||
page.wait_for_timeout(400)
|
||||
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
|
||||
|
||||
|
||||
def test_pointer_drag_reorder_works_twice_in_a_row(two_periods_setup) -> None:
|
||||
page = two_periods_setup
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
|
||||
|
||||
first = pointer_drag_reorder_recipe(page, E2E_DISP_RECIPE_1, direction="down")
|
||||
assert first.get("ok"), first
|
||||
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
|
||||
|
||||
page.wait_for_timeout(400)
|
||||
second = pointer_drag_reorder_recipe(page, E2E_DISP_RECIPE_1, direction="up")
|
||||
assert second.get("ok"), second
|
||||
assert second.get("reorderActive"), second
|
||||
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
|
||||
|
||||
|
||||
def test_pointer_drag_reorder_swaps_dom_order(two_periods_setup) -> None:
|
||||
"""Pointer-path: ловит баг, когда transfer endSession рвёт активный reorder."""
|
||||
page = two_periods_setup
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
|
||||
|
||||
result = pointer_drag_reorder_recipe(page, E2E_DISP_RECIPE_1, direction="down")
|
||||
assert result.get("ok"), result
|
||||
assert result.get("reorderActive"), "pointer reorder should activate"
|
||||
assert result.get("idsAfter") == [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1], result
|
||||
|
||||
|
||||
def test_move_recipe_up_changes_api_order(two_periods_setup, live_server_url) -> None:
|
||||
SCENARIO_E2E_MOVE_RECIPE_UP # noqa: F841
|
||||
page = two_periods_setup
|
||||
page.locator(
|
||||
f'[data-action="move-recipe-down"][data-recipe-id="{E2E_DISP_RECIPE_1}"]'
|
||||
).click()
|
||||
page.wait_for_timeout(500)
|
||||
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_A) == [
|
||||
E2E_DISP_RECIPE_2,
|
||||
E2E_DISP_RECIPE_1,
|
||||
]
|
||||
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "PUT" and "/move" in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator(
|
||||
f'[data-action="move-recipe-up"][data-recipe-id="{E2E_DISP_RECIPE_1}"]'
|
||||
).click()
|
||||
page.wait_for_timeout(500)
|
||||
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
|
||||
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_A) == [
|
||||
E2E_DISP_RECIPE_1,
|
||||
E2E_DISP_RECIPE_2,
|
||||
]
|
||||
|
||||
|
||||
def test_editor_add_remove_unloading_group(dispenser_recipe_open) -> None:
|
||||
SCENARIO_E2E_EDITOR_UNLOADING_GROUP # noqa: F841
|
||||
page = dispenser_recipe_open
|
||||
rows = page.locator("#unloadingGroupsTableBody .unloading-group-row")
|
||||
before = rows.count()
|
||||
assert before >= 1
|
||||
page.locator('[data-action="add-unloading-group"]').click()
|
||||
page.wait_for_timeout(300)
|
||||
assert rows.count() == before + 1
|
||||
page.locator('[data-action="remove-unloading-group"]').last.click()
|
||||
page.wait_for_timeout(200)
|
||||
assert rows.count() == before
|
||||
|
||||
|
||||
def test_editor_cancel_discards_unsaved_name(dispenser_recipe_open) -> None:
|
||||
SCENARIO_E2E_EDITOR_CANCEL # noqa: F841
|
||||
page = dispenser_recipe_open
|
||||
original = page.locator("#recipeName").input_value()
|
||||
page.locator("#recipeName").fill("E2E Не должно сохраниться")
|
||||
page.locator('[data-action="show-recipe-edit-off"]').first.click()
|
||||
page.wait_for_timeout(400)
|
||||
assert page.locator("#recipeEdit").is_hidden() or not page.locator("#recipeEdit").is_visible()
|
||||
|
||||
page.locator(f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]').click()
|
||||
page.wait_for_selector("#recipeEdit", state="visible", timeout=15000)
|
||||
assert page.locator("#recipeName").input_value() == original
|
||||
|
||||
|
||||
def test_editor_group_value_change_updates_input(dispenser_recipe_open) -> None:
|
||||
page = dispenser_recipe_open
|
||||
inp = page.locator(
|
||||
"#unloadingGroupsTableBody .unloading-group-row [data-action='group-value-change']"
|
||||
).first
|
||||
assert inp.is_visible()
|
||||
inp.fill("77.5")
|
||||
assert inp.input_value() == "77.5"
|
||||
@@ -0,0 +1,159 @@
|
||||
"""E2E: два терминала — DnD/sync (баготест #4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.e2e.recipes_e2e_helpers import (
|
||||
drag_reorder_recipe,
|
||||
list_dom_recipe_ids,
|
||||
period_recipe_ids,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
SCENARIO_DUAL_E2E_DND_SYNC = "DUAL_E2E_DND_SYNC"
|
||||
SCENARIO_DUAL_E2E_TRANSFER_REORDER = "DUAL_E2E_TRANSFER_REORDER"
|
||||
|
||||
|
||||
def test_dual_reorder_visible_on_both_terminals(
|
||||
page_a, page_b, dual_harness, live_server_a_url, live_server_b_url
|
||||
) -> None:
|
||||
SCENARIO_DUAL_E2E_DND_SYNC # noqa: F841
|
||||
disp_id = dual_harness._e2e_disp_id # type: ignore[attr-defined]
|
||||
period_a = dual_harness._e2e_period_a # type: ignore[attr-defined]
|
||||
r1 = dual_harness._e2e_r1 # type: ignore[attr-defined]
|
||||
r2 = dual_harness._e2e_r2 # type: ignore[attr-defined]
|
||||
|
||||
for page, url in ((page_a, live_server_a_url), (page_b, live_server_b_url)):
|
||||
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{disp_id}"]').click()
|
||||
page.locator(f'[data-action="select-period"][data-period-id="{period_a}"]').click()
|
||||
page.wait_for_selector("#recipesList .list-item", timeout=10000)
|
||||
|
||||
assert list_dom_recipe_ids(page_a) == [r1, r2]
|
||||
assert list_dom_recipe_ids(page_b) == [r1, r2]
|
||||
|
||||
drag_reorder_recipe(page_a, r1, direction="down")
|
||||
page_a.wait_for_timeout(600)
|
||||
from tests.e2e.dual_terminal_conftest import sync_both
|
||||
|
||||
sync_both(dual_harness)
|
||||
page_b.reload()
|
||||
page_b.wait_for_selector("#recipesDashboard", timeout=15000)
|
||||
page_b.locator(f'[data-action="select-dispenser"][data-dispenser-id="{disp_id}"]').click()
|
||||
page_b.locator(f'[data-action="select-period"][data-period-id="{period_a}"]').click()
|
||||
page_b.wait_for_timeout(500)
|
||||
|
||||
assert period_recipe_ids(page_a, live_server_a_url, period_a) == [r2, r1]
|
||||
assert period_recipe_ids(page_b, live_server_b_url, period_a) == [r2, r1]
|
||||
|
||||
|
||||
def test_dual_edit_recipe_name_both_see(
|
||||
page_a, page_b, dual_harness, live_server_a_url, live_server_b_url
|
||||
) -> None:
|
||||
r1 = dual_harness._e2e_r1 # type: ignore[attr-defined]
|
||||
current = page_a.request.get(f"{live_server_a_url}/api/recipes/{r1}")
|
||||
assert current.ok
|
||||
payload = current.json()
|
||||
payload["name"] = "Dual renamed"
|
||||
payload["ingredients"] = payload.get("ingredients") or []
|
||||
payload["unloading_groups"] = payload.get("unloading_groups") or []
|
||||
|
||||
put = page_a.request.put(f"{live_server_a_url}/api/recipes/{r1}", data=payload)
|
||||
assert put.ok, put.text()
|
||||
|
||||
dual_harness.push_from_terminal("a")
|
||||
with dual_harness.server_ctx():
|
||||
from app import db
|
||||
from app.models import Recipe
|
||||
from app.services.sync_manager import enqueue_sync_queue_task
|
||||
|
||||
row = db.session.get(Recipe, r1)
|
||||
if row is not None:
|
||||
row.name = "Dual renamed"
|
||||
db.session.commit()
|
||||
enqueue_sync_queue_task("recipe", r1, "update", priority=2)
|
||||
db.session.commit()
|
||||
|
||||
from tests.e2e.dual_terminal_conftest import sync_both
|
||||
|
||||
sync_both(dual_harness)
|
||||
|
||||
for page, url in ((page_a, live_server_a_url), (page_b, live_server_b_url)):
|
||||
resp = page.request.get(f"{url}/api/recipes/{r1}")
|
||||
assert resp.ok
|
||||
assert resp.json().get("name") == "Dual renamed"
|
||||
|
||||
|
||||
def test_dual_copy_paste_unloading_groups(
|
||||
page_a, dual_harness, live_server_a_url, live_server_b_url, page_b
|
||||
) -> None:
|
||||
"""После paste на A sync — B видит рецепт в period B (API)."""
|
||||
period_b = dual_harness._e2e_period_b # type: ignore[attr-defined]
|
||||
r1 = dual_harness._e2e_r1 # type: ignore[attr-defined]
|
||||
|
||||
src = page_a.request.get(f"{live_server_a_url}/api/recipes/{r1}")
|
||||
assert src.ok
|
||||
body = src.json()
|
||||
body["name"] = f"{body.get('name', 'R')} (копия)"
|
||||
body["ingredients"] = body.get("ingredients") or []
|
||||
body["unloading_groups"] = body.get("unloading_groups") or [
|
||||
{"name": "G1", "distribution_type": "percent", "value": 100, "order": 1}
|
||||
]
|
||||
create = page_a.request.post(
|
||||
f"{live_server_a_url}/api/periods/{period_b}/recipes",
|
||||
data=body,
|
||||
)
|
||||
assert create.ok or create.status in (200, 201), create.text()
|
||||
new_id = create.json().get("id") if create.ok else None
|
||||
|
||||
dual_harness.push_from_terminal("a")
|
||||
if new_id:
|
||||
with dual_harness.server_ctx():
|
||||
from app.services.sync_manager import enqueue_sync_queue_task
|
||||
|
||||
enqueue_sync_queue_task("recipe", new_id, "create", priority=2)
|
||||
enqueue_sync_queue_task(
|
||||
"period_recipes", f"{period_b}:{new_id}", "create", priority=2
|
||||
)
|
||||
from app import db
|
||||
|
||||
db.session.commit()
|
||||
|
||||
from tests.e2e.dual_terminal_conftest import sync_both
|
||||
|
||||
sync_both(dual_harness)
|
||||
|
||||
listed = page_b.request.get(f"{live_server_b_url}/api/periods/{period_b}/recipes")
|
||||
assert listed.ok
|
||||
assert len(listed.json()) >= 1
|
||||
rid = new_id or listed.json()[0]["id"]
|
||||
detail = page_b.request.get(f"{live_server_b_url}/api/recipes/{rid}")
|
||||
assert detail.ok
|
||||
groups = detail.json().get("unloading_groups") or []
|
||||
assert groups
|
||||
|
||||
|
||||
def test_dual_transfer_then_reorder_on_b(
|
||||
page_a, page_b, dual_harness, live_server_a_url, live_server_b_url
|
||||
) -> None:
|
||||
SCENARIO_DUAL_E2E_TRANSFER_REORDER # noqa: F841
|
||||
disp_id = dual_harness._e2e_disp_id # type: ignore[attr-defined]
|
||||
period_a = dual_harness._e2e_period_a # type: ignore[attr-defined]
|
||||
period_b = dual_harness._e2e_period_b # type: ignore[attr-defined]
|
||||
r2 = dual_harness._e2e_r2 # type: ignore[attr-defined]
|
||||
|
||||
from tests.e2e.recipes_e2e_helpers import drag_transfer_to_period
|
||||
|
||||
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()
|
||||
drag_transfer_to_period(page_a, r2, period_b)
|
||||
|
||||
from tests.e2e.dual_terminal_conftest import sync_both
|
||||
|
||||
sync_both(dual_harness)
|
||||
|
||||
page_b.locator(f'[data-action="select-dispenser"][data-dispenser-id="{disp_id}"]').click()
|
||||
page_b.locator(f'[data-action="select-period"][data-period-id="{period_b}"]').click()
|
||||
ids_b = period_recipe_ids(page_b, live_server_b_url, period_b)
|
||||
assert r2 in ids_b
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Playwright E2E: mobile viewport /recipes — dashboard steps, dispenser flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.e2e.conftest import E2E_DISP_ID, E2E_DISP_RECIPE_1, E2E_PERIOD_A
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
MOBILE_VIEWPORT = {"width": 390, "height": 844}
|
||||
|
||||
|
||||
def test_mobile_dispenser_period_recipe_steps(authenticated_page) -> None:
|
||||
page = authenticated_page
|
||||
page.set_viewport_size(MOBILE_VIEWPORT)
|
||||
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_DISP_ID}"]').click()
|
||||
page.wait_for_timeout(400)
|
||||
assert page.evaluate(
|
||||
"() => document.body.classList.contains('dashboard-mobile-step-periods')"
|
||||
)
|
||||
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
|
||||
)
|
||||
assert page.evaluate(
|
||||
"() => document.body.classList.contains('dashboard-mobile-step-recipes')"
|
||||
)
|
||||
|
||||
|
||||
def test_mobile_back_from_recipes_to_periods(authenticated_page) -> None:
|
||||
page = authenticated_page
|
||||
page.set_viewport_size(MOBILE_VIEWPORT)
|
||||
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_DISP_ID}"]').click()
|
||||
page.locator(f'[data-action="select-period"][data-period-id="{E2E_PERIOD_A}"]').click()
|
||||
page.wait_for_selector('[data-action="mobile-dashboard-back"]', timeout=10000)
|
||||
page.locator('[data-action="mobile-dashboard-back"]').click()
|
||||
page.wait_for_timeout(400)
|
||||
assert page.evaluate(
|
||||
"() => document.body.classList.contains('dashboard-mobile-step-periods')"
|
||||
)
|
||||
|
||||
|
||||
def test_mobile_drag_handles_present_on_recipe_list(two_periods_setup) -> None:
|
||||
page = two_periods_setup
|
||||
page.set_viewport_size(MOBILE_VIEWPORT)
|
||||
assert page.locator('[data-action="recipe-drag-handle"]').count() >= 2
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Playwright E2E: страница /recipes — dashboard, editor, mill, save payload."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.e2e.conftest import E2E_ING_A, E2E_MILL_ID, E2E_RECIPE_ID
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
def test_login_and_recipes_dashboard_visible(authenticated_page) -> None:
|
||||
page = authenticated_page
|
||||
expect = page.locator("#recipesDashboard")
|
||||
expect.wait_for(state="visible")
|
||||
assert page.locator("#dispensersCol").is_visible()
|
||||
# recipesCol скрыт (dashboard-col--idle) до выбора периода/кормоцеха
|
||||
assert page.locator("#recipesCol").count() == 1
|
||||
|
||||
|
||||
def test_dashboard_lists_dispenser_and_mill(authenticated_page) -> None:
|
||||
page = authenticated_page
|
||||
page.wait_for_selector("#dispensersList .list-item", timeout=10000)
|
||||
assert page.locator(f'[data-dispenser-id="{E2E_MILL_ID}"]').count() == 1
|
||||
|
||||
|
||||
def test_select_mill_shows_recipes_and_create_button(authenticated_page) -> None:
|
||||
page = authenticated_page
|
||||
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
|
||||
page.wait_for_selector(
|
||||
f'[data-action="select-recipe"][data-recipe-id="{E2E_RECIPE_ID}"]', timeout=10000
|
||||
)
|
||||
assert page.locator("#createRecipeBtn").is_visible()
|
||||
title = page.locator(
|
||||
f'[data-action="select-recipe"][data-recipe-id="{E2E_RECIPE_ID}"] .recipe-title-text'
|
||||
)
|
||||
assert "E2E Два компонента" in title.inner_text()
|
||||
|
||||
|
||||
def test_open_recipe_editor_shows_form_fields(mill_recipe_open) -> None:
|
||||
page = mill_recipe_open
|
||||
assert page.locator("#recipeName").input_value() == "E2E Два компонента"
|
||||
assert page.locator("#headsCount").is_visible()
|
||||
assert page.locator("#componentSelectionCard").is_visible()
|
||||
assert page.locator("#targetComponentSelect").is_visible()
|
||||
rows = page.locator("#ingredientsTableBody .zt-data-grid__row")
|
||||
assert rows.count() == 2
|
||||
|
||||
|
||||
def test_editor_action_buttons_present(mill_recipe_open) -> None:
|
||||
page = mill_recipe_open
|
||||
assert page.locator('[data-action="add-ingredient"]').is_visible()
|
||||
assert page.locator('[data-action="save-and-exit"]').is_visible()
|
||||
assert page.locator('[data-action="show-recipe-edit-off"]').count() >= 1
|
||||
assert page.locator('[data-action="remove-ingredient"]').count() == 2
|
||||
|
||||
|
||||
def test_dry_matter_lock_recalculates_on_save(mill_recipe_open, live_server_url) -> None:
|
||||
SCENARIO_SAVE_DRY_MATTER_LOCKED = "SAVE_DRY_MATTER_LOCKED" # noqa: F841
|
||||
page = mill_recipe_open
|
||||
page.locator("#toggleDryMatterMode").click()
|
||||
dm_percent = page.locator("#ingredientsTableBody .dry-matter-percent-input").first
|
||||
dm_percent.fill("55")
|
||||
dm_percent.dispatch_event("input")
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "PUT" and E2E_RECIPE_ID in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator('[data-action="save-and-exit"]').click()
|
||||
|
||||
saved = page.request.get(f"{live_server_url}/api/recipes/{E2E_RECIPE_ID}")
|
||||
assert saved.ok
|
||||
assert saved.json().get("dry_matter_locked") is True
|
||||
|
||||
|
||||
def test_dry_matter_lock_toggle_changes_icon(mill_recipe_open) -> None:
|
||||
page = mill_recipe_open
|
||||
icon = page.locator("#dryMatterIcon")
|
||||
before = icon.get_attribute("class") or ""
|
||||
page.locator("#toggleDryMatterMode").click()
|
||||
after = icon.get_attribute("class") or ""
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_remove_ingredient_and_save_sends_deleted_ids(mill_recipe_open, live_server_url) -> None:
|
||||
page = mill_recipe_open
|
||||
captured: dict = {}
|
||||
|
||||
def on_route(route, request):
|
||||
if request.method == "PUT" and f"/api/recipes/{E2E_RECIPE_ID}" in request.url:
|
||||
captured["body"] = request.post_data_json
|
||||
route.continue_()
|
||||
else:
|
||||
route.continue_()
|
||||
|
||||
page.route("**/api/recipes/**", on_route)
|
||||
page.locator('[data-action="remove-ingredient"]').first.click()
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "PUT" and E2E_RECIPE_ID in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator('[data-action="save-and-exit"]').click()
|
||||
|
||||
assert "body" in captured, "PUT /api/recipes not intercepted"
|
||||
body = captured["body"]
|
||||
deleted = body.get("deleted_ingredient_ids") or []
|
||||
assert E2E_ING_A in deleted or str(E2E_ING_A) in [str(x) for x in deleted]
|
||||
ingredients = body.get("ingredients") or []
|
||||
assert len(ingredients) == 1
|
||||
assert ingredients[0].get("id") is not None
|
||||
|
||||
|
||||
def test_recipe_edit_skeleton_hides_after_load(mill_recipe_open) -> None:
|
||||
page = mill_recipe_open
|
||||
skel = page.locator("#recipeEditSkeletonPanel")
|
||||
assert skel.is_hidden() or skel.get_attribute("hidden") is not None
|
||||
|
||||
|
||||
def test_mobile_nav_back_exists(authenticated_page) -> None:
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 390, "height": 844})
|
||||
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
|
||||
page.wait_for_selector('[data-action="mobile-dashboard-back"]', timeout=10000)
|
||||
assert page.locator('[data-action="mobile-dashboard-back"]').count() == 1
|
||||
|
||||
|
||||
def test_settings_open_settings_action_in_nav(authenticated_page, live_server_url) -> None:
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
settings_btn = page.locator('[data-action="open-settings"]')
|
||||
assert settings_btn.count() >= 1
|
||||
settings_btn.first.click()
|
||||
page.wait_for_selector('[data-action="close-settings"]', timeout=5000)
|
||||
assert page.locator('[data-action="toggle-sync-form"]').is_visible()
|
||||
|
||||
|
||||
def test_add_ingredient_increases_row_count(mill_recipe_open) -> None:
|
||||
page = mill_recipe_open
|
||||
before = page.locator("#ingredientsTableBody .zt-data-grid__row").count()
|
||||
page.locator('[data-action="add-ingredient"]').click()
|
||||
page.wait_for_timeout(300)
|
||||
after = page.locator("#ingredientsTableBody .zt-data-grid__row").count()
|
||||
assert after == before + 1
|
||||
|
||||
|
||||
def test_unloading_link_toggle_button_visible(mill_recipe_open) -> None:
|
||||
page = mill_recipe_open
|
||||
btn = page.locator('[data-action="toggle-unloading-link"]')
|
||||
assert btn.is_visible()
|
||||
icon_before = page.locator("#unloadingLinkIcon").get_attribute("class") or ""
|
||||
btn.click()
|
||||
icon_after = page.locator("#unloadingLinkIcon").get_attribute("class") or ""
|
||||
assert icon_before != icon_after
|
||||
|
||||
|
||||
def test_unloading_link_toggle_persists_after_save_and_reload(
|
||||
mill_recipe_open, live_server_url
|
||||
) -> None:
|
||||
page = mill_recipe_open
|
||||
recipe_sel = f'[data-action="select-recipe"][data-recipe-id="{E2E_RECIPE_ID}"]'
|
||||
|
||||
page.locator('[data-action="toggle-unloading-link"]').click()
|
||||
assert "fa-unlink" in (page.locator("#unloadingLinkIcon").get_attribute("class") or "")
|
||||
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "PUT" and E2E_RECIPE_ID in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator('[data-action="save-and-exit"]').click()
|
||||
|
||||
resp = page.request.get(f"{live_server_url}/api/recipes/{E2E_RECIPE_ID}")
|
||||
assert resp.ok
|
||||
assert resp.json().get("unloading_link_broken") is True
|
||||
|
||||
page.locator(recipe_sel).click()
|
||||
page.wait_for_selector("#recipeEdit", state="visible", timeout=15000)
|
||||
assert "fa-unlink" in (page.locator("#unloadingLinkIcon").get_attribute("class") or "")
|
||||
|
||||
|
||||
def test_help_modal_opens_and_closes(mill_recipe_open) -> None:
|
||||
page = mill_recipe_open
|
||||
page.set_viewport_size({"width": 390, "height": 844})
|
||||
page.locator('[data-action="open-recipe-help"]').first.click()
|
||||
page.wait_for_selector("body.recipe-help-modal-open", timeout=5000)
|
||||
page.locator('[data-action="close-recipe-help"]').first.click()
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
|
||||
def test_save_updates_recipe_name_on_server(mill_recipe_open, live_server_url) -> None:
|
||||
page = mill_recipe_open
|
||||
page.locator("#recipeName").fill("E2E Переименован")
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "PUT" and E2E_RECIPE_ID in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator('[data-action="save-and-exit"]').click()
|
||||
resp = page.request.get(f"{live_server_url}/api/recipes/{E2E_RECIPE_ID}")
|
||||
assert resp.ok
|
||||
data = resp.json()
|
||||
assert data.get("name") == "E2E Переименован"
|
||||
|
||||
|
||||
def test_mill_create_recipe_button_opens_editor(authenticated_page) -> None:
|
||||
page = authenticated_page
|
||||
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
|
||||
page.wait_for_selector("#createRecipeBtn", timeout=10000)
|
||||
page.locator("#createRecipeBtn").click()
|
||||
page.wait_for_selector("#recipeEdit", state="visible", timeout=15000)
|
||||
|
||||
|
||||
def test_mill_delete_recipe_global(authenticated_page, live_server_url) -> None:
|
||||
page = authenticated_page
|
||||
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
|
||||
page.wait_for_selector("#createRecipeBtn", timeout=10000)
|
||||
page.locator("#createRecipeBtn").click()
|
||||
page.wait_for_selector("#recipeEdit", state="visible", timeout=15000)
|
||||
page.locator("#recipeName").fill("E2E Удалить")
|
||||
|
||||
created: dict = {}
|
||||
|
||||
def capture_create(route, request):
|
||||
if request.method == "POST" and request.url.rstrip("/").endswith("/api/recipes"):
|
||||
created["url"] = request.url
|
||||
route.continue_()
|
||||
else:
|
||||
route.continue_()
|
||||
|
||||
page.route("**/api/recipes**", capture_create)
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "POST" and r.url.rstrip("/").endswith("/api/recipes"),
|
||||
timeout=20000,
|
||||
) as resp_info:
|
||||
page.locator('[data-action="save-and-exit"]').click()
|
||||
page.unroute("**/api/recipes**")
|
||||
body = resp_info.value.json()
|
||||
recipe_id = body.get("id")
|
||||
assert recipe_id, body
|
||||
|
||||
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
|
||||
page.wait_for_selector(
|
||||
f'[data-action="select-recipe"][data-recipe-id="{recipe_id}"]', timeout=10000
|
||||
)
|
||||
page.on("dialog", lambda d: d.accept())
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "DELETE" and recipe_id in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator(f'[data-action="delete-recipe"][data-recipe-id="{recipe_id}"]').click()
|
||||
resp = page.request.get(f"{live_server_url}/api/recipes/{recipe_id}")
|
||||
assert resp.status == 404
|
||||
|
||||
|
||||
def test_print_recipe_button_does_not_crash(mill_recipe_open) -> None:
|
||||
page = mill_recipe_open
|
||||
btn = page.locator('[data-action="print-recipe"]')
|
||||
assert btn.count() >= 1, "кнопка print-recipe должна быть в редакторе"
|
||||
page.evaluate("() => { window.print = () => {}; }")
|
||||
btn.first.click()
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
|
||||
def test_group_type_select_present(mill_recipe_open) -> None:
|
||||
page = mill_recipe_open
|
||||
selects = page.locator('[data-action="group-type-change"]')
|
||||
assert selects.count() >= 1, "в seed есть группа выгрузки"
|
||||
assert selects.first.is_visible()
|
||||
|
||||
|
||||
def test_mobile_ingredient_sheet_open_close(mill_recipe_open) -> None:
|
||||
page = mill_recipe_open
|
||||
page.set_viewport_size({"width": 390, "height": 844})
|
||||
page.wait_for_timeout(400)
|
||||
open_btn = page.locator(
|
||||
"#ingredientsTableBody .ingredient-row [data-action='open-ingredient-sheet']"
|
||||
)
|
||||
assert open_btn.count() >= 1, "на mobile должен быть summary-кнопка ингредиента"
|
||||
open_btn.first.click()
|
||||
sheet = page.locator("#ingredientMobileSheet")
|
||||
sheet.wait_for(state="visible", timeout=5000)
|
||||
assert sheet.evaluate("el => !el.hidden")
|
||||
page.locator('[data-action="close-ingredient-sheet"]').last.click()
|
||||
sheet.wait_for(state="hidden", timeout=5000)
|
||||
assert sheet.evaluate("el => el.hidden")
|
||||
|
||||
|
||||
def test_dry_matter_percent_input_accepts_change(mill_recipe_open) -> None:
|
||||
page = mill_recipe_open
|
||||
inp = page.locator('[data-action="dry-matter-percent-change"]').first
|
||||
assert inp.is_visible()
|
||||
before = inp.input_value()
|
||||
inp.fill("42.5")
|
||||
assert inp.input_value() == "42.5"
|
||||
inp.fill(before)
|
||||
|
||||
|
||||
def test_print_all_recipes_button_does_not_crash(mill_recipe_open) -> None:
|
||||
page = mill_recipe_open
|
||||
btn = page.locator('[data-action="print-all-recipes"]')
|
||||
assert btn.is_visible()
|
||||
page.evaluate("() => { window.print = () => {}; }")
|
||||
btn.click()
|
||||
page.wait_for_timeout(300)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Playwright E2E: drag-transfer рейса между периодами."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.e2e.conftest import (
|
||||
E2E_DISP_ID,
|
||||
E2E_DISP_RECIPE_1,
|
||||
E2E_DISP_RECIPE_2,
|
||||
E2E_PERIOD_A,
|
||||
E2E_PERIOD_B,
|
||||
)
|
||||
from tests.e2e.recipes_e2e_helpers import (
|
||||
LONG_HOVER_MS,
|
||||
drag_reorder_recipe,
|
||||
drag_transfer_to_period,
|
||||
list_dom_recipe_ids,
|
||||
period_recipe_ids,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
SCENARIO_E2E_TRANSFER_ESCAPE = "E2E_TRANSFER_ESCAPE"
|
||||
SCENARIO_E2E_TRANSFER_REPEAT = "E2E_TRANSFER_REPEAT"
|
||||
SCENARIO_E2E_REORDER_THEN_TRANSFER = "E2E_REORDER_THEN_TRANSFER"
|
||||
SCENARIO_E2E_TRANSFER_THEN_REORDER = "E2E_TRANSFER_THEN_REORDER"
|
||||
|
||||
|
||||
def _open_period_a(page, recipe_id: str = E2E_DISP_RECIPE_1) -> None:
|
||||
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_DISP_ID}"]').click()
|
||||
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="{recipe_id}"]', timeout=10000
|
||||
)
|
||||
|
||||
|
||||
def test_transfer_escape_cancels_without_api_call(authenticated_page) -> None:
|
||||
SCENARIO_E2E_TRANSFER_ESCAPE # noqa: F841
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_open_period_a(page)
|
||||
handle = page.locator(
|
||||
f'#recipesList .list-item[data-recipe-id="{E2E_DISP_RECIPE_1}"] [data-action="recipe-drag-handle"]'
|
||||
).first
|
||||
box = handle.bounding_box()
|
||||
assert box
|
||||
page.mouse.move(box["x"] + 5, box["y"] + 5)
|
||||
page.mouse.down()
|
||||
page.mouse.move(box["x"] + 200, box["y"] + 200, steps=8)
|
||||
page.keyboard.press("Escape")
|
||||
page.mouse.up()
|
||||
assert page.locator("button.recipe-transfer-slot").count() == 0
|
||||
|
||||
|
||||
def test_transfer_drag_to_other_period_inserts_recipe(authenticated_page, live_server_url) -> None:
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_open_period_a(page)
|
||||
|
||||
handle = page.locator(
|
||||
f'#recipesList .list-item[data-recipe-id="{E2E_DISP_RECIPE_1}"] [data-action="recipe-drag-handle"]'
|
||||
).first
|
||||
period_b = page.locator(f'[data-action="select-period"][data-period-id="{E2E_PERIOD_B}"]')
|
||||
|
||||
handle_box = handle.bounding_box()
|
||||
period_box = period_b.bounding_box()
|
||||
assert handle_box and period_box
|
||||
|
||||
page.mouse.move(handle_box["x"] + handle_box["width"] / 2, handle_box["y"] + 5)
|
||||
page.mouse.down()
|
||||
page.mouse.move(period_box["x"] + period_box["width"] / 2, period_box["y"] + 10, steps=12)
|
||||
page.wait_for_timeout(LONG_HOVER_MS)
|
||||
page.mouse.up()
|
||||
|
||||
slot = page.locator("button.recipe-transfer-slot")
|
||||
page.wait_for_selector("button.recipe-transfer-slot", timeout=10000)
|
||||
with page.expect_response(
|
||||
lambda r: "/transfer" in r.url and r.request.method == "POST",
|
||||
timeout=15000,
|
||||
):
|
||||
slot.first.click()
|
||||
|
||||
page.wait_for_timeout(500)
|
||||
listed = page.request.get(f"{live_server_url}/api/periods/{E2E_PERIOD_B}/recipes")
|
||||
assert listed.ok
|
||||
ids = [r["id"] for r in listed.json()]
|
||||
assert E2E_DISP_RECIPE_1 in ids
|
||||
|
||||
|
||||
def test_transfer_repeat_a_b_a(authenticated_page, live_server_url) -> None:
|
||||
SCENARIO_E2E_TRANSFER_REPEAT # noqa: F841
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_open_period_a(page)
|
||||
drag_transfer_to_period(page, E2E_DISP_RECIPE_1, E2E_PERIOD_B)
|
||||
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_A) == [E2E_DISP_RECIPE_2]
|
||||
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_B) == [E2E_DISP_RECIPE_1]
|
||||
|
||||
page.locator(f'[data-action="select-period"][data-period-id="{E2E_PERIOD_B}"]').click()
|
||||
page.wait_for_selector(
|
||||
f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]', timeout=10000
|
||||
)
|
||||
drag_transfer_to_period(page, E2E_DISP_RECIPE_1, E2E_PERIOD_A, slot_index=1)
|
||||
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_A) == [
|
||||
E2E_DISP_RECIPE_2,
|
||||
E2E_DISP_RECIPE_1,
|
||||
]
|
||||
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_B) == []
|
||||
|
||||
|
||||
def test_transfer_slot_index_one_when_target_has_recipe(
|
||||
authenticated_page, live_server_url
|
||||
) -> None:
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_open_period_a(page)
|
||||
drag_transfer_to_period(page, E2E_DISP_RECIPE_2, E2E_PERIOD_B)
|
||||
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_B) == [E2E_DISP_RECIPE_2]
|
||||
|
||||
_open_period_a(page, E2E_DISP_RECIPE_1)
|
||||
drag_transfer_to_period(page, E2E_DISP_RECIPE_1, E2E_PERIOD_B, slot_index=0)
|
||||
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_B) == [
|
||||
E2E_DISP_RECIPE_1,
|
||||
E2E_DISP_RECIPE_2,
|
||||
]
|
||||
|
||||
|
||||
def test_reorder_then_transfer_still_moves_recipe(authenticated_page, live_server_url) -> None:
|
||||
SCENARIO_E2E_REORDER_THEN_TRANSFER # noqa: F841
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_open_period_a(page)
|
||||
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
|
||||
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "PUT" and "/move" in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
drag_reorder_recipe(page, E2E_DISP_RECIPE_1, direction="down")
|
||||
page.wait_for_timeout(500)
|
||||
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
|
||||
|
||||
drag_transfer_to_period(page, E2E_DISP_RECIPE_1, E2E_PERIOD_B)
|
||||
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_A) == [E2E_DISP_RECIPE_2]
|
||||
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_B) == [E2E_DISP_RECIPE_1]
|
||||
|
||||
|
||||
def test_transfer_then_reorder_on_target_period(authenticated_page, live_server_url) -> None:
|
||||
SCENARIO_E2E_TRANSFER_THEN_REORDER # noqa: F841
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_open_period_a(page)
|
||||
drag_transfer_to_period(page, E2E_DISP_RECIPE_1, E2E_PERIOD_B)
|
||||
_open_period_a(page, E2E_DISP_RECIPE_2)
|
||||
drag_transfer_to_period(page, E2E_DISP_RECIPE_2, E2E_PERIOD_B, slot_index=1)
|
||||
|
||||
page.locator(f'[data-action="select-period"][data-period-id="{E2E_PERIOD_B}"]').click()
|
||||
page.wait_for_selector(
|
||||
f'#recipesList .list-item[data-recipe-id="{E2E_DISP_RECIPE_2}"]', timeout=10000
|
||||
)
|
||||
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
|
||||
|
||||
drag_reorder_recipe(page, E2E_DISP_RECIPE_2, direction="up", expect_move_response=False)
|
||||
page.wait_for_timeout(500)
|
||||
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
|
||||
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_B) == [
|
||||
E2E_DISP_RECIPE_2,
|
||||
E2E_DISP_RECIPE_1,
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Playwright E2E: настройки /recipes — sync panel, credentials, logout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
def test_settings_panel_open_close(authenticated_page) -> None:
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
open_btn = page.locator('[data-action="open-settings"]')
|
||||
assert open_btn.count() >= 1, "open-settings должен быть в навигации"
|
||||
open_btn.first.click(force=True)
|
||||
page.wait_for_selector('[data-action="close-settings"]', timeout=5000)
|
||||
assert page.locator('[data-action="toggle-sync-form"]').is_visible()
|
||||
page.locator('#settingsModal [data-action="close-settings"]').first.click()
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
|
||||
def test_toggle_sync_form(authenticated_page) -> None:
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
page.locator('[data-action="open-settings"]').first.click()
|
||||
page.wait_for_selector('[data-action="toggle-sync-form"]', timeout=5000)
|
||||
page.locator('[data-action="toggle-sync-form"]').click()
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
|
||||
def test_toggle_credentials_form(authenticated_page) -> None:
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
page.locator('[data-action="open-settings"]').first.click(force=True)
|
||||
cred = page.locator('[data-action="toggle-credentials-form"]')
|
||||
cred.wait_for(state="visible", timeout=5000)
|
||||
cred.click()
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
|
||||
def test_logout_clears_session(authenticated_page, live_server_url) -> None:
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
with page.expect_response(
|
||||
lambda r: r.url.endswith("/api/auth/logout") and r.request.method == "POST",
|
||||
timeout=10000,
|
||||
):
|
||||
page.locator('[data-action="logout"]').first.click()
|
||||
page.wait_for_url(f"{live_server_url}/", timeout=10000)
|
||||
check = page.request.get(f"{live_server_url}/api/auth/check")
|
||||
assert check.ok
|
||||
assert check.json().get("authenticated") is False
|
||||
|
||||
|
||||
def test_mobile_dashboard_back(authenticated_page) -> None:
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 390, "height": 844})
|
||||
from tests.e2e.conftest import E2E_MILL_ID
|
||||
|
||||
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
|
||||
page.wait_for_selector('[data-action="mobile-dashboard-back"]', timeout=10000)
|
||||
page.locator('[data-action="mobile-dashboard-back"]').click()
|
||||
page.wait_for_timeout(400)
|
||||
@@ -0,0 +1,667 @@
|
||||
"""E2E user journeys: хаотичные длинные сценарии «полный еблан за терминалом»."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.e2e.conftest import (
|
||||
E2E_DISP_ID,
|
||||
E2E_DISP_RECIPE_1,
|
||||
E2E_DISP_RECIPE_2,
|
||||
E2E_MILL_ID,
|
||||
E2E_PERIOD_A,
|
||||
E2E_PERIOD_B,
|
||||
E2E_RECIPE_ID,
|
||||
)
|
||||
from tests.e2e.recipes_e2e_helpers import (
|
||||
abort_transfer_via_escape,
|
||||
assert_period_order,
|
||||
click_copy_recipe,
|
||||
click_move_recipe,
|
||||
create_recipe_in_period,
|
||||
delete_recipe_unlink,
|
||||
drag_reorder_recipe,
|
||||
drag_transfer_to_period,
|
||||
ensure_two_recipe_order,
|
||||
fetch_recipe,
|
||||
list_dom_recipe_ids,
|
||||
open_dispenser_period,
|
||||
period_recipe_ids,
|
||||
recipe_ingredient_snapshot,
|
||||
restore_two_recipe_order_if_reversed,
|
||||
select_period,
|
||||
wait_editor_closed,
|
||||
wait_editor_open,
|
||||
wait_mobile_dashboard_step,
|
||||
wait_period_order,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
MOBILE_VIEWPORT = {"width": 390, "height": 844}
|
||||
E2E_DISP_ING_1 = "e2e-disp-ing-1"
|
||||
|
||||
SCENARIO_JOURNEY_MORNING_ROUTINE = "JOURNEY_MORNING_ROUTINE"
|
||||
SCENARIO_JOURNEY_TRANSFER_REPEAT_SLOTS = "JOURNEY_TRANSFER_REPEAT_SLOTS"
|
||||
SCENARIO_JOURNEY_ESCAPE_REORDER_MIX = "JOURNEY_ESCAPE_REORDER_MIX"
|
||||
SCENARIO_JOURNEY_MILL_CREATE_DELETE = "JOURNEY_MILL_CREATE_DELETE"
|
||||
SCENARIO_JOURNEY_SETTINGS_LOGOUT = "JOURNEY_SETTINGS_LOGOUT"
|
||||
SCENARIO_JOURNEY_EMPTY_THEN_FULL = "JOURNEY_EMPTY_THEN_FULL"
|
||||
SCENARIO_JOURNEY_MOBILE_FLIP = "JOURNEY_MOBILE_FLIP"
|
||||
SCENARIO_JOURNEY_EDITOR_CANCEL_REOPEN = "JOURNEY_EDITOR_CANCEL_REOPEN"
|
||||
SCENARIO_JOURNEY_MOVE_BUTTONS_YOYO = "JOURNEY_MOVE_BUTTONS_YOYO"
|
||||
SCENARIO_JOURNEY_DOUBLE_COPY_PASTE = "JOURNEY_DOUBLE_COPY_PASTE"
|
||||
SCENARIO_JOURNEY_MILL_DISPENSER_PINGPONG = "JOURNEY_MILL_DISPENSER_PINGPONG"
|
||||
SCENARIO_JOURNEY_DISTRACTED_UI = "JOURNEY_DISTRACTED_UI"
|
||||
SCENARIO_JOURNEY_MEGA_MIX = "JOURNEY_MEGA_MIX"
|
||||
|
||||
|
||||
def _open_dispenser_period_a(page, live_server_url) -> None:
|
||||
open_dispenser_period(
|
||||
page,
|
||||
E2E_PERIOD_A,
|
||||
wait_recipe_id=E2E_DISP_RECIPE_1,
|
||||
live_server_url=live_server_url,
|
||||
expected_order=[E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2],
|
||||
)
|
||||
|
||||
|
||||
def test_journey_morning_routine_save_reorder_copy_paste_unlink(
|
||||
authenticated_page, live_server_url
|
||||
) -> None:
|
||||
SCENARIO_JOURNEY_MORNING_ROUTINE # noqa: F841
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_open_dispenser_period_a(page, live_server_url)
|
||||
|
||||
page.locator(f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]').click()
|
||||
wait_editor_open(page)
|
||||
page.locator("#recipeName").fill("E2E Утренний рейс")
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "PUT" and E2E_DISP_RECIPE_1 in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator('[data-action="save-and-exit"]').click()
|
||||
wait_editor_closed(page)
|
||||
assert fetch_recipe(page, live_server_url, E2E_DISP_RECIPE_1)["name"] == "E2E Утренний рейс"
|
||||
assert_period_order(
|
||||
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
|
||||
)
|
||||
|
||||
_open_dispenser_period_a(page, live_server_url)
|
||||
drag_reorder_recipe(
|
||||
page,
|
||||
E2E_DISP_RECIPE_1,
|
||||
direction="down",
|
||||
live_server_url=live_server_url,
|
||||
period_id=E2E_PERIOD_A,
|
||||
expected_order=[E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1],
|
||||
)
|
||||
|
||||
click_copy_recipe(page, E2E_DISP_RECIPE_1)
|
||||
select_period(page, E2E_PERIOD_B, live_server_url=live_server_url, expected_order=[])
|
||||
page.locator(f'[data-action="paste-recipe"][data-period-id="{E2E_PERIOD_B}"]').click()
|
||||
wait_editor_open(page)
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "POST"
|
||||
and f"/api/periods/{E2E_PERIOD_B}/recipes" in r.url,
|
||||
timeout=20000,
|
||||
) as paste_save:
|
||||
page.locator('[data-action="save-and-exit"]').click()
|
||||
pasted_id = paste_save.value.json().get("id")
|
||||
assert pasted_id
|
||||
wait_editor_closed(page)
|
||||
wait_period_order(page, live_server_url, E2E_PERIOD_B, [pasted_id])
|
||||
|
||||
select_period(page, E2E_PERIOD_B, wait_recipe_id=pasted_id)
|
||||
delete_recipe_unlink(
|
||||
page,
|
||||
pasted_id,
|
||||
live_server_url=live_server_url,
|
||||
period_id=E2E_PERIOD_B,
|
||||
expected_period_order=[],
|
||||
)
|
||||
assert page.request.get(f"{live_server_url}/api/recipes/{pasted_id}").ok
|
||||
|
||||
|
||||
def test_journey_transfer_repeat_with_slots(authenticated_page, live_server_url) -> None:
|
||||
SCENARIO_JOURNEY_TRANSFER_REPEAT_SLOTS # noqa: F841
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_open_dispenser_period_a(page, live_server_url)
|
||||
|
||||
drag_transfer_to_period(
|
||||
page,
|
||||
E2E_DISP_RECIPE_1,
|
||||
E2E_PERIOD_B,
|
||||
live_server_url=live_server_url,
|
||||
source_period_id=E2E_PERIOD_A,
|
||||
expected_source_order=[E2E_DISP_RECIPE_2],
|
||||
expected_target_order=[E2E_DISP_RECIPE_1],
|
||||
)
|
||||
assert page.locator("button.recipe-transfer-slot").count() == 0
|
||||
|
||||
select_period(
|
||||
page,
|
||||
E2E_PERIOD_B,
|
||||
wait_recipe_id=E2E_DISP_RECIPE_1,
|
||||
live_server_url=live_server_url,
|
||||
expected_order=[E2E_DISP_RECIPE_1],
|
||||
)
|
||||
drag_transfer_to_period(
|
||||
page,
|
||||
E2E_DISP_RECIPE_1,
|
||||
E2E_PERIOD_A,
|
||||
slot_index=1,
|
||||
live_server_url=live_server_url,
|
||||
source_period_id=E2E_PERIOD_B,
|
||||
expected_source_order=[],
|
||||
expected_target_order=[E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1],
|
||||
)
|
||||
|
||||
|
||||
def test_journey_escape_then_reorder_still_works(authenticated_page, live_server_url) -> None:
|
||||
SCENARIO_JOURNEY_ESCAPE_REORDER_MIX # noqa: F841
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_open_dispenser_period_a(page, live_server_url)
|
||||
|
||||
abort_transfer_via_escape(page, E2E_DISP_RECIPE_1)
|
||||
assert_period_order(
|
||||
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
|
||||
)
|
||||
|
||||
drag_reorder_recipe(
|
||||
page,
|
||||
E2E_DISP_RECIPE_1,
|
||||
direction="down",
|
||||
live_server_url=live_server_url,
|
||||
period_id=E2E_PERIOD_A,
|
||||
expected_order=[E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1],
|
||||
)
|
||||
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
|
||||
|
||||
|
||||
def test_journey_mill_create_edit_delete_global(authenticated_page, live_server_url) -> None:
|
||||
SCENARIO_JOURNEY_MILL_CREATE_DELETE # noqa: F841
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
|
||||
page.locator("#createRecipeBtn").click()
|
||||
wait_editor_open(page)
|
||||
page.locator("#recipeName").fill("E2E Journey Mill")
|
||||
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "POST" and r.url.rstrip("/").endswith("/api/recipes"),
|
||||
timeout=20000,
|
||||
) as resp_info:
|
||||
page.locator('[data-action="save-and-exit"]').click()
|
||||
recipe_id = resp_info.value.json().get("id")
|
||||
assert recipe_id
|
||||
wait_editor_closed(page)
|
||||
assert fetch_recipe(page, live_server_url, recipe_id)["name"] == "E2E Journey Mill"
|
||||
|
||||
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
|
||||
page.locator(f'[data-action="select-recipe"][data-recipe-id="{recipe_id}"]').click()
|
||||
wait_editor_open(page)
|
||||
before_ings = len(fetch_recipe(page, live_server_url, recipe_id).get("ingredients") or [])
|
||||
page.locator('[data-action="add-ingredient"]').click()
|
||||
page.wait_for_function(
|
||||
f"() => document.querySelectorAll('#ingredientsTableBody .zt-data-grid__row').length > {before_ings}"
|
||||
)
|
||||
page.locator('[data-action="remove-ingredient"]').first.click()
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "PUT" and recipe_id in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator('[data-action="save-and-exit"]').click()
|
||||
wait_editor_closed(page)
|
||||
assert len(fetch_recipe(page, live_server_url, recipe_id).get("ingredients") or []) == before_ings
|
||||
|
||||
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
|
||||
page.on("dialog", lambda d: d.accept())
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "DELETE" and recipe_id in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator(f'[data-action="delete-recipe"][data-recipe-id="{recipe_id}"]').click()
|
||||
assert page.request.get(f"{live_server_url}/api/recipes/{recipe_id}").status == 404
|
||||
assert page.request.get(f"{live_server_url}/api/recipes/{E2E_RECIPE_ID}").ok
|
||||
|
||||
|
||||
def test_journey_settings_then_logout(authenticated_page, live_server_url) -> None:
|
||||
SCENARIO_JOURNEY_SETTINGS_LOGOUT # noqa: F841
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
page.locator('[data-action="open-settings"]').first.click(force=True)
|
||||
page.wait_for_selector('[data-action="toggle-sync-form"]', timeout=5000)
|
||||
page.locator('[data-action="toggle-sync-form"]').click()
|
||||
page.locator('[data-action="toggle-credentials-form"]').click()
|
||||
page.locator('#settingsModal [data-action="close-settings"]').first.click()
|
||||
page.wait_for_selector('[data-action="open-settings"]', state="visible", timeout=5000)
|
||||
|
||||
with page.expect_response(
|
||||
lambda r: r.url.endswith("/api/auth/logout") and r.request.method == "POST",
|
||||
timeout=10000,
|
||||
):
|
||||
page.locator('[data-action="logout"]').first.click()
|
||||
page.wait_for_url(f"{live_server_url}/", timeout=10000)
|
||||
check = page.request.get(f"{live_server_url}/api/auth/check")
|
||||
assert check.ok
|
||||
assert check.json().get("authenticated") is False
|
||||
|
||||
|
||||
def test_journey_empty_period_create_then_delete_all(authenticated_page, live_server_url) -> None:
|
||||
SCENARIO_JOURNEY_EMPTY_THEN_FULL # noqa: F841
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_DISP_ID}"]').click()
|
||||
select_period(
|
||||
page,
|
||||
E2E_PERIOD_B,
|
||||
live_server_url=live_server_url,
|
||||
expected_order=[],
|
||||
)
|
||||
|
||||
created_ids: list[str] = []
|
||||
for i in range(2):
|
||||
page.wait_for_selector(
|
||||
f'[data-action="create-recipe"][data-period-id="{E2E_PERIOD_B}"]',
|
||||
state="visible",
|
||||
timeout=10000,
|
||||
)
|
||||
rid = create_recipe_in_period(
|
||||
page,
|
||||
E2E_PERIOD_B,
|
||||
f"E2E Пустой B {i}",
|
||||
live_server_url=live_server_url,
|
||||
)
|
||||
created_ids.append(rid)
|
||||
wait_period_order(page, live_server_url, E2E_PERIOD_B, created_ids.copy())
|
||||
|
||||
assert len(period_recipe_ids(page, live_server_url, E2E_PERIOD_B)) == 2
|
||||
|
||||
for rid in created_ids:
|
||||
select_period(page, E2E_PERIOD_B, wait_recipe_id=rid)
|
||||
remaining = [x for x in created_ids if x != rid]
|
||||
delete_recipe_unlink(
|
||||
page,
|
||||
rid,
|
||||
live_server_url=live_server_url,
|
||||
period_id=E2E_PERIOD_B,
|
||||
expected_period_order=remaining,
|
||||
)
|
||||
|
||||
assert_period_order(page, live_server_url, E2E_PERIOD_B, [])
|
||||
|
||||
|
||||
def test_journey_mobile_flip_back_and_button_reorder(
|
||||
authenticated_page, live_server_url
|
||||
) -> None:
|
||||
SCENARIO_JOURNEY_MOBILE_FLIP # noqa: F841
|
||||
page = authenticated_page
|
||||
page.set_viewport_size(MOBILE_VIEWPORT)
|
||||
open_dispenser_period(
|
||||
page,
|
||||
E2E_PERIOD_A,
|
||||
wait_recipe_id=E2E_DISP_RECIPE_1,
|
||||
live_server_url=live_server_url,
|
||||
expected_order=[E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2],
|
||||
)
|
||||
|
||||
page.locator(f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]').click()
|
||||
wait_editor_open(page)
|
||||
page.locator('[data-action="show-recipe-edit-off"]').first.click()
|
||||
wait_editor_closed(page)
|
||||
|
||||
page.locator('[data-action="mobile-dashboard-back"]').click()
|
||||
wait_mobile_dashboard_step(page, "periods")
|
||||
|
||||
select_period(
|
||||
page,
|
||||
E2E_PERIOD_A,
|
||||
wait_recipe_id=E2E_DISP_RECIPE_1,
|
||||
live_server_url=live_server_url,
|
||||
expected_order=[E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2],
|
||||
)
|
||||
click_move_recipe(page, E2E_DISP_RECIPE_1, "down")
|
||||
wait_period_order(
|
||||
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
|
||||
)
|
||||
|
||||
page.locator('[data-action="mobile-dashboard-back"]').click()
|
||||
wait_mobile_dashboard_step(page, "periods")
|
||||
select_period(
|
||||
page,
|
||||
E2E_PERIOD_B,
|
||||
live_server_url=live_server_url,
|
||||
expected_order=[],
|
||||
)
|
||||
page.locator('[data-action="mobile-dashboard-back"]').click()
|
||||
wait_mobile_dashboard_step(page, "periods")
|
||||
select_period(
|
||||
page,
|
||||
E2E_PERIOD_A,
|
||||
wait_recipe_id=E2E_DISP_RECIPE_1,
|
||||
live_server_url=live_server_url,
|
||||
expected_order=[E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1],
|
||||
)
|
||||
click_move_recipe(page, E2E_DISP_RECIPE_1, "up")
|
||||
wait_period_order(
|
||||
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
|
||||
)
|
||||
|
||||
|
||||
def test_journey_editor_cancel_then_save_other_recipe(
|
||||
authenticated_page, live_server_url
|
||||
) -> None:
|
||||
SCENARIO_JOURNEY_EDITOR_CANCEL_REOPEN # noqa: F841
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_open_dispenser_period_a(page, live_server_url)
|
||||
|
||||
before_name = fetch_recipe(page, live_server_url, E2E_DISP_RECIPE_1)["name"]
|
||||
before_ings = recipe_ingredient_snapshot(page, live_server_url, E2E_DISP_RECIPE_1)
|
||||
assert len(before_ings) == 1
|
||||
assert before_ings[0][0] == E2E_DISP_ING_1
|
||||
|
||||
page.locator(f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]').click()
|
||||
wait_editor_open(page)
|
||||
page.locator("#recipeName").fill("E2E Не должно сохраниться")
|
||||
page.locator('[data-action="add-ingredient"]').click()
|
||||
page.wait_for_function(
|
||||
"() => document.querySelectorAll('#ingredientsTableBody .ingredient-row').length > 1"
|
||||
)
|
||||
page.locator("#ingredientsTableBody .weight-per-head-input").first.fill("99.9")
|
||||
page.locator('[data-action="show-recipe-edit-off"]').first.click()
|
||||
wait_editor_closed(page)
|
||||
|
||||
assert fetch_recipe(page, live_server_url, E2E_DISP_RECIPE_1)["name"] == before_name
|
||||
assert recipe_ingredient_snapshot(page, live_server_url, E2E_DISP_RECIPE_1) == before_ings
|
||||
|
||||
page.locator(f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_2}"]').click()
|
||||
wait_editor_open(page)
|
||||
page.locator("#recipeName").fill("E2E Второй переименован")
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "PUT" and E2E_DISP_RECIPE_2 in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator('[data-action="save-and-exit"]').click()
|
||||
wait_editor_closed(page)
|
||||
|
||||
assert fetch_recipe(page, live_server_url, E2E_DISP_RECIPE_1)["name"] == before_name
|
||||
assert recipe_ingredient_snapshot(page, live_server_url, E2E_DISP_RECIPE_1) == before_ings
|
||||
assert fetch_recipe(page, live_server_url, E2E_DISP_RECIPE_2)["name"] == "E2E Второй переименован"
|
||||
|
||||
|
||||
def test_journey_move_buttons_yoyo(authenticated_page, live_server_url) -> None:
|
||||
SCENARIO_JOURNEY_MOVE_BUTTONS_YOYO # noqa: F841
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_open_dispenser_period_a(page, live_server_url)
|
||||
|
||||
click_move_recipe(page, E2E_DISP_RECIPE_1, "down")
|
||||
wait_period_order(
|
||||
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
|
||||
)
|
||||
|
||||
click_move_recipe(page, E2E_DISP_RECIPE_1, "up")
|
||||
wait_period_order(
|
||||
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
|
||||
)
|
||||
|
||||
click_move_recipe(page, E2E_DISP_RECIPE_1, "down")
|
||||
wait_period_order(
|
||||
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
|
||||
)
|
||||
|
||||
|
||||
def test_journey_double_copy_paste_same_buffer(authenticated_page, live_server_url) -> None:
|
||||
SCENARIO_JOURNEY_DOUBLE_COPY_PASTE # noqa: F841
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_open_dispenser_period_a(page, live_server_url)
|
||||
|
||||
click_copy_recipe(page, E2E_DISP_RECIPE_1)
|
||||
select_period(page, E2E_PERIOD_B, live_server_url=live_server_url, expected_order=[])
|
||||
|
||||
pasted_ids: list[str] = []
|
||||
for n in range(2):
|
||||
page.locator(f'[data-action="paste-recipe"][data-period-id="{E2E_PERIOD_B}"]').click()
|
||||
wait_editor_open(page)
|
||||
page.locator("#recipeName").fill(f"E2E Двойная копия {n}")
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "POST"
|
||||
and f"/api/periods/{E2E_PERIOD_B}/recipes" in r.url,
|
||||
timeout=20000,
|
||||
) as resp:
|
||||
page.locator('[data-action="save-and-exit"]').click()
|
||||
rid = resp.value.json().get("id")
|
||||
assert rid
|
||||
pasted_ids.append(rid)
|
||||
wait_editor_closed(page)
|
||||
wait_period_order(page, live_server_url, E2E_PERIOD_B, pasted_ids.copy())
|
||||
|
||||
assert_period_order(page, live_server_url, E2E_PERIOD_B, pasted_ids)
|
||||
|
||||
for rid in pasted_ids:
|
||||
select_period(page, E2E_PERIOD_B, wait_recipe_id=rid)
|
||||
remaining = [x for x in pasted_ids if x != rid]
|
||||
delete_recipe_unlink(
|
||||
page,
|
||||
rid,
|
||||
live_server_url=live_server_url,
|
||||
period_id=E2E_PERIOD_B,
|
||||
expected_period_order=remaining,
|
||||
)
|
||||
|
||||
assert_period_order(page, live_server_url, E2E_PERIOD_B, [])
|
||||
|
||||
|
||||
def test_journey_mill_dispenser_pingpong(authenticated_page, live_server_url) -> None:
|
||||
SCENARIO_JOURNEY_MILL_DISPENSER_PINGPONG # noqa: F841
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
|
||||
mill_before = fetch_recipe(page, live_server_url, E2E_RECIPE_ID)["name"]
|
||||
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
|
||||
page.locator(f'[data-action="select-recipe"][data-recipe-id="{E2E_RECIPE_ID}"]').click()
|
||||
wait_editor_open(page)
|
||||
page.locator("#recipeName").fill("E2E Mill черновик")
|
||||
page.locator('[data-action="show-recipe-edit-off"]').first.click()
|
||||
wait_editor_closed(page)
|
||||
assert fetch_recipe(page, live_server_url, E2E_RECIPE_ID)["name"] == mill_before
|
||||
|
||||
_open_dispenser_period_a(page, live_server_url)
|
||||
disp_before = recipe_ingredient_snapshot(page, live_server_url, E2E_DISP_RECIPE_1)
|
||||
page.locator(f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]').click()
|
||||
wait_editor_open(page)
|
||||
page.locator("#toggleDryMatterMode").click()
|
||||
page.locator('[data-action="show-recipe-edit-off"]').first.click()
|
||||
wait_editor_closed(page)
|
||||
assert recipe_ingredient_snapshot(page, live_server_url, E2E_DISP_RECIPE_1) == disp_before
|
||||
|
||||
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
|
||||
page.locator(f'[data-action="select-recipe"][data-recipe-id="{E2E_RECIPE_ID}"]').click()
|
||||
wait_editor_open(page)
|
||||
page.locator("#recipeName").fill("E2E Mill сохранён")
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "PUT" and E2E_RECIPE_ID in r.url,
|
||||
timeout=15000,
|
||||
):
|
||||
page.locator('[data-action="save-and-exit"]').click()
|
||||
wait_editor_closed(page)
|
||||
assert fetch_recipe(page, live_server_url, E2E_RECIPE_ID)["name"] == "E2E Mill сохранён"
|
||||
|
||||
_open_dispenser_period_a(page, live_server_url)
|
||||
drag_transfer_to_period(
|
||||
page,
|
||||
E2E_DISP_RECIPE_1,
|
||||
E2E_PERIOD_B,
|
||||
live_server_url=live_server_url,
|
||||
source_period_id=E2E_PERIOD_A,
|
||||
expected_source_order=[E2E_DISP_RECIPE_2],
|
||||
expected_target_order=[E2E_DISP_RECIPE_1],
|
||||
)
|
||||
|
||||
|
||||
def test_journey_distracted_help_settings_then_transfer(
|
||||
authenticated_page, live_server_url
|
||||
) -> None:
|
||||
SCENARIO_JOURNEY_DISTRACTED_UI # noqa: F841
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_open_dispenser_period_a(page, live_server_url)
|
||||
|
||||
page.locator(f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]').click()
|
||||
wait_editor_open(page)
|
||||
page.set_viewport_size(MOBILE_VIEWPORT)
|
||||
page.locator('[data-action="open-recipe-help"]').first.click()
|
||||
page.wait_for_selector("body.recipe-help-modal-open", timeout=5000)
|
||||
page.locator('[data-action="close-recipe-help"]').first.click()
|
||||
page.wait_for_function("() => !document.body.classList.contains('recipe-help-modal-open')")
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
|
||||
page.locator('[data-action="open-settings"]').first.click(force=True)
|
||||
page.locator('[data-action="toggle-sync-form"]').click()
|
||||
page.locator('[data-action="toggle-credentials-form"]').click()
|
||||
page.locator('#settingsModal [data-action="close-settings"]').first.click()
|
||||
page.wait_for_selector("#settingsModal", state="hidden", timeout=5000)
|
||||
|
||||
page.locator('[data-action="show-recipe-edit-off"]').first.click()
|
||||
wait_editor_closed(page)
|
||||
restore_two_recipe_order_if_reversed(
|
||||
page, live_server_url, E2E_PERIOD_A, E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2
|
||||
)
|
||||
assert_period_order(
|
||||
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
|
||||
)
|
||||
|
||||
abort_transfer_via_escape(page, E2E_DISP_RECIPE_2)
|
||||
restore_two_recipe_order_if_reversed(
|
||||
page, live_server_url, E2E_PERIOD_A, E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2
|
||||
)
|
||||
assert_period_order(
|
||||
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
|
||||
)
|
||||
|
||||
drag_transfer_to_period(
|
||||
page,
|
||||
E2E_DISP_RECIPE_2,
|
||||
E2E_PERIOD_B,
|
||||
live_server_url=live_server_url,
|
||||
source_period_id=E2E_PERIOD_A,
|
||||
expected_source_order=[E2E_DISP_RECIPE_1],
|
||||
expected_target_order=[E2E_DISP_RECIPE_2],
|
||||
)
|
||||
|
||||
|
||||
def test_journey_mega_mix_all_operations(authenticated_page, live_server_url) -> None:
|
||||
"""Мега-сценарий: всё подряд, как реальный зоотехник в понедельник утром."""
|
||||
SCENARIO_JOURNEY_MEGA_MIX # noqa: F841
|
||||
page = authenticated_page
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
|
||||
page.locator('[data-action="open-settings"]').first.click(force=True)
|
||||
page.locator('#settingsModal [data-action="close-settings"]').first.click()
|
||||
page.wait_for_selector("#settingsModal", state="hidden", timeout=5000)
|
||||
|
||||
_open_dispenser_period_a(page, live_server_url)
|
||||
select_period(page, E2E_PERIOD_B, live_server_url=live_server_url, expected_order=[])
|
||||
select_period(
|
||||
page,
|
||||
E2E_PERIOD_A,
|
||||
wait_recipe_id=E2E_DISP_RECIPE_1,
|
||||
live_server_url=live_server_url,
|
||||
expected_order=[E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2],
|
||||
)
|
||||
|
||||
click_move_recipe(page, E2E_DISP_RECIPE_1, "down")
|
||||
wait_period_order(
|
||||
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
|
||||
)
|
||||
|
||||
abort_transfer_via_escape(page, E2E_DISP_RECIPE_1)
|
||||
ensure_two_recipe_order(
|
||||
page,
|
||||
live_server_url,
|
||||
E2E_PERIOD_A,
|
||||
[E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1],
|
||||
)
|
||||
assert_period_order(
|
||||
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
|
||||
)
|
||||
ensure_two_recipe_order(
|
||||
page,
|
||||
live_server_url,
|
||||
E2E_PERIOD_A,
|
||||
[E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2],
|
||||
)
|
||||
assert_period_order(
|
||||
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
|
||||
)
|
||||
|
||||
click_copy_recipe(page, E2E_DISP_RECIPE_2)
|
||||
drag_transfer_to_period(
|
||||
page,
|
||||
E2E_DISP_RECIPE_1,
|
||||
E2E_PERIOD_B,
|
||||
live_server_url=live_server_url,
|
||||
source_period_id=E2E_PERIOD_A,
|
||||
expected_source_order=[E2E_DISP_RECIPE_2],
|
||||
expected_target_order=[E2E_DISP_RECIPE_1],
|
||||
)
|
||||
|
||||
select_period(
|
||||
page,
|
||||
E2E_PERIOD_B,
|
||||
wait_recipe_id=E2E_DISP_RECIPE_1,
|
||||
live_server_url=live_server_url,
|
||||
expected_order=[E2E_DISP_RECIPE_1],
|
||||
)
|
||||
page.locator(f'[data-action="paste-recipe"][data-period-id="{E2E_PERIOD_B}"]').click()
|
||||
wait_editor_open(page)
|
||||
with page.expect_response(
|
||||
lambda r: r.request.method == "POST"
|
||||
and f"/api/periods/{E2E_PERIOD_B}/recipes" in r.url,
|
||||
timeout=20000,
|
||||
) as paste_resp:
|
||||
page.locator('[data-action="save-and-exit"]').click()
|
||||
pasted_id = paste_resp.value.json().get("id")
|
||||
assert pasted_id
|
||||
wait_editor_closed(page)
|
||||
wait_period_order(
|
||||
page, live_server_url, E2E_PERIOD_B, [E2E_DISP_RECIPE_1, pasted_id]
|
||||
)
|
||||
|
||||
open_dispenser_period(
|
||||
page,
|
||||
E2E_PERIOD_B,
|
||||
wait_recipe_id=E2E_DISP_RECIPE_1,
|
||||
live_server_url=live_server_url,
|
||||
expected_order=[E2E_DISP_RECIPE_1, pasted_id],
|
||||
)
|
||||
click_move_recipe(page, pasted_id, "up")
|
||||
wait_period_order(
|
||||
page, live_server_url, E2E_PERIOD_B, [pasted_id, E2E_DISP_RECIPE_1]
|
||||
)
|
||||
|
||||
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
|
||||
page.wait_for_selector("#createRecipeBtn", timeout=10000)
|
||||
assert fetch_recipe(page, live_server_url, E2E_RECIPE_ID).get("name")
|
||||
|
||||
open_dispenser_period(
|
||||
page,
|
||||
E2E_PERIOD_B,
|
||||
wait_recipe_id=pasted_id,
|
||||
live_server_url=live_server_url,
|
||||
expected_order=[pasted_id, E2E_DISP_RECIPE_1],
|
||||
)
|
||||
delete_recipe_unlink(
|
||||
page,
|
||||
pasted_id,
|
||||
live_server_url=live_server_url,
|
||||
period_id=E2E_PERIOD_B,
|
||||
expected_period_order=[E2E_DISP_RECIPE_1],
|
||||
)
|
||||
assert page.request.get(f"{live_server_url}/api/recipes/{pasted_id}").ok
|
||||
@@ -0,0 +1,14 @@
|
||||
"""E2E smoke: /reports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
def test_reports_page_loads(zootech_authenticated_page, live_server_url) -> None:
|
||||
page = zootech_authenticated_page
|
||||
page.goto(f"{live_server_url}/reports")
|
||||
page.wait_for_load_state("domcontentloaded")
|
||||
assert page.locator("body").is_visible()
|
||||
@@ -0,0 +1,13 @@
|
||||
"""E2E smoke: /unloading (localhost kiosk bypass)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
def test_unloading_page_loads(page, live_server_url) -> None:
|
||||
page.goto(f"{live_server_url}/unloading")
|
||||
page.wait_for_load_state("domcontentloaded")
|
||||
assert page.locator("body").is_visible()
|
||||
Reference in New Issue
Block a user