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

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+1
View File
@@ -0,0 +1 @@
"""Браузерные E2E-тесты WESP."""
+343
View File
@@ -0,0 +1,343 @@
"""Фикстуры Playwright E2E: live_server, auth, seed данных /recipes и zootech."""
from __future__ import annotations
import os
import socket
import tempfile
import threading
from typing import Generator, Tuple
import pytest
from werkzeug.serving import make_server
pytest.importorskip("playwright")
from app import create_app, db
from app.models import Component, FeedDispenser, Ingredient, Recipe, UnloadingGroup
from app.services.setup_state import mark_setup_complete
from config import TestingConfig
from tests.helpers.dispenser_recipe_fixtures import (
E2E_DISP_ID,
E2E_DISP_RECIPE_1,
E2E_DISP_RECIPE_2,
E2E_PERIOD_A,
E2E_PERIOD_B,
reset_e2e_dispenser_period_state,
seed_dispenser_period_recipes,
)
E2E_MILL_ID = "e2e-mill-1"
E2E_RECIPE_ID = "e2e-recipe-1"
E2E_COMP_A = "e2e-comp-a"
E2E_COMP_B = "e2e-comp-b"
E2E_ING_A = "e2e-ing-a"
E2E_ING_B = "e2e-ing-b"
E2E_LOGIN = "recipes-e2e-admin"
E2E_PASSWORD = "recipes-e2e-secret"
class RecipesE2EConfig(TestingConfig):
_TMP_DIR = tempfile.mkdtemp(prefix="wesp-recipes-e2e-")
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(_TMP_DIR, 'recipes_e2e.db')}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(_TMP_DIR, 'reports_e2e.db')}"}
AUTH_LOGIN = E2E_LOGIN
AUTH_PASSWORD = E2E_PASSWORD
TESTING = True
def _free_port() -> int:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
def _seed_e2e_recipes_data() -> None:
db.session.add_all(
[
FeedDispenser(
id=E2E_MILL_ID,
name="E2E Кормоцех",
farm="Ферма",
operator="Тест",
type="mill",
content_hash="",
),
Component(
id=E2E_COMP_A,
name="E2E Компонент A",
type="grain",
dry_matter=50.0,
protein=0.0,
energy=0.0,
price=0.0,
),
Component(
id=E2E_COMP_B,
name="E2E Компонент B",
type="grain",
dry_matter=60.0,
protein=0.0,
energy=0.0,
price=0.0,
),
Recipe(
id=E2E_RECIPE_ID,
name="E2E Два компонента",
heads_per_trip=10,
mixing_time=5,
trip_percent=100.0,
target_component_id=E2E_COMP_A,
content_hash="",
),
Ingredient(
id=E2E_ING_A,
name="Ing A",
weight_per_head=1.0,
amount=10.0,
dry_matter=50.0,
component_id=E2E_COMP_A,
order=1,
recipe_id=E2E_RECIPE_ID,
created_by="system",
updated_by="system",
),
Ingredient(
id=E2E_ING_B,
name="Ing B",
weight_per_head=2.0,
amount=20.0,
dry_matter=60.0,
component_id=E2E_COMP_B,
order=2,
recipe_id=E2E_RECIPE_ID,
created_by="system",
updated_by="system",
),
UnloadingGroup(
id="e2e-grp-1",
name="E2E Группа",
distribution_type="percent",
value=100.0,
weight=30.0,
order=1,
recipe_id=E2E_RECIPE_ID,
created_by="system",
updated_by="system",
),
]
)
db.session.commit()
@pytest.fixture(autouse=True)
def _reset_e2e_dispenser_state(e2e_app) -> None:
with e2e_app.app_context():
reset_e2e_dispenser_period_state()
for rid, default_name in (
(E2E_RECIPE_ID, "E2E Два компонента"),
(E2E_DISP_RECIPE_1, "E2E Рейс 1"),
(E2E_DISP_RECIPE_2, "E2E Рейс 2"),
):
recipe = db.session.get(Recipe, rid)
if recipe is not None:
recipe.name = default_name
recipe.unloading_link_broken = False
db.session.commit()
@pytest.fixture(scope="session")
def e2e_app():
app = create_app(RecipesE2EConfig)
with app.app_context():
db.create_all()
mark_setup_complete(app)
_seed_e2e_recipes_data()
seed_dispenser_period_recipes()
yield app
with app.app_context():
db.session.remove()
db.drop_all()
@pytest.fixture(scope="session")
def live_server_url(e2e_app) -> Generator[str, None, None]:
port = _free_port()
server = make_server("127.0.0.1", port, e2e_app, threaded=True)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{port}"
server.shutdown()
@pytest.fixture
def zootech_authenticated_page(page, live_server_url):
"""Залогиненная страница для zootech-страниц (не только /recipes)."""
resp = page.request.post(
f"{live_server_url}/api/auth/login",
headers={"Content-Type": "application/json"},
data='{"login":"%s","password":"%s"}' % (E2E_LOGIN, E2E_PASSWORD),
)
assert resp.ok, resp.text()
return page
@pytest.fixture
def authenticated_page(page, live_server_url):
"""Страница с сессией зоотехника после POST /api/auth/login."""
resp = page.request.post(
f"{live_server_url}/api/auth/login",
headers={"Content-Type": "application/json"},
data='{"login":"%s","password":"%s"}' % (E2E_LOGIN, E2E_PASSWORD),
)
assert resp.ok, resp.text()
page.goto(f"{live_server_url}/recipes")
page.wait_for_selector("#recipesDashboard", state="visible", timeout=15000)
return page
@pytest.fixture
def mill_recipe_open(authenticated_page, live_server_url):
"""Открытый редактор рецепта кормоцеха с двумя ингредиентами."""
page = authenticated_page
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
recipe_sel = f'[data-action="select-recipe"][data-recipe-id="{E2E_RECIPE_ID}"]'
page.wait_for_selector(recipe_sel, timeout=10000)
page.locator(recipe_sel).click()
page.wait_for_selector("#recipeEdit", state="visible", timeout=15000)
page.wait_for_selector("#ingredientsTableBody .zt-data-grid__row", timeout=10000)
return page
@pytest.fixture
def dispenser_dashboard_open(authenticated_page):
"""Dashboard кормораздатчика: выбран dispenser, виден period."""
page = authenticated_page
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_DISP_ID}"]').click()
page.wait_for_selector(
f'[data-action="select-period"][data-period-id="{E2E_PERIOD_A}"]', timeout=10000
)
return page
@pytest.fixture
def dispenser_recipe_open(dispenser_dashboard_open):
"""Редактор рецепта кормораздатчика в period A."""
page = dispenser_dashboard_open
page.locator(f'[data-action="select-period"][data-period-id="{E2E_PERIOD_A}"]').click()
recipe_sel = f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]'
page.wait_for_selector(recipe_sel, timeout=10000)
page.locator(recipe_sel).click()
page.wait_for_selector("#recipeEdit", state="visible", timeout=15000)
page.wait_for_selector("#unloadingGroupsTableBody .unloading-group-row", timeout=10000)
return page
@pytest.fixture
def two_periods_setup(dispenser_dashboard_open):
"""Dispenser с двумя периодами — period A выбран, список рейсов виден."""
page = dispenser_dashboard_open
page.locator(f'[data-action="select-period"][data-period-id="{E2E_PERIOD_A}"]').click()
page.wait_for_selector(
f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]', timeout=10000
)
return page
# --- Dual-terminal E2E (SyncDualInstanceHarness + два Werkzeug-сервера) ---
from tests.helpers.sync_dual_harness import SyncDualInstanceHarness
def _dual_free_port() -> int:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
def _dual_start_server(app, port: int):
server = make_server("127.0.0.1", port, app, threaded=True)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server
def _dual_login_page(page, base_url: str) -> None:
login = SyncDualInstanceHarness.AUTH_LOGIN
password = SyncDualInstanceHarness.AUTH_PASSWORD
resp = page.request.post(
f"{base_url}/api/auth/login",
headers={"Content-Type": "application/json"},
data='{"login":"%s","password":"%s"}' % (login, password),
)
assert resp.ok, resp.text()
@pytest.fixture(scope="module")
def dual_harness() -> Generator[SyncDualInstanceHarness, None, None]:
harness = SyncDualInstanceHarness()
harness.start()
disp_id, period_a, period_b, r1, r2 = harness.seed_dispenser_two_periods_two_recipes()
harness._e2e_disp_id = disp_id # type: ignore[attr-defined]
harness._e2e_period_a = period_a # type: ignore[attr-defined]
harness._e2e_period_b = period_b # type: ignore[attr-defined]
harness._e2e_r1 = r1 # type: ignore[attr-defined]
harness._e2e_r2 = r2 # type: ignore[attr-defined]
yield harness
harness.stop()
@pytest.fixture(scope="module")
def live_server_a_url(dual_harness) -> Generator[str, None, None]:
port = _dual_free_port()
server = _dual_start_server(dual_harness.term_a_app, port)
yield f"http://127.0.0.1:{port}"
server.shutdown()
@pytest.fixture(scope="module")
def live_server_b_url(dual_harness) -> Generator[str, None, None]:
port = _dual_free_port()
server = _dual_start_server(dual_harness.term_b_app, port)
yield f"http://127.0.0.1:{port}"
server.shutdown()
@pytest.fixture
def page_a(browser, live_server_a_url) -> Generator:
context = browser.new_context(viewport={"width": 1280, "height": 800})
page = context.new_page()
_dual_login_page(page, live_server_a_url)
page.goto(f"{live_server_a_url}/recipes")
page.wait_for_selector("#recipesDashboard", timeout=15000)
yield page
context.close()
@pytest.fixture
def page_b(browser, live_server_b_url) -> Generator:
context = browser.new_context(viewport={"width": 1280, "height": 800})
page = context.new_page()
_dual_login_page(page, live_server_b_url)
page.goto(f"{live_server_b_url}/recipes")
page.wait_for_selector("#recipesDashboard", timeout=15000)
yield page
context.close()
@pytest.fixture
def dual_dispenser_period_a(page_a, dual_harness, live_server_a_url):
disp_id = dual_harness._e2e_disp_id # type: ignore[attr-defined]
period_a = dual_harness._e2e_period_a # type: ignore[attr-defined]
page_a.locator(f'[data-action="select-dispenser"][data-dispenser-id="{disp_id}"]').click()
page_a.locator(f'[data-action="select-period"][data-period-id="{period_a}"]').click()
page_a.wait_for_selector("#recipesList .list-item", timeout=10000)
return page_a, dual_harness, live_server_a_url, period_a
def sync_both(dual_harness: SyncDualInstanceHarness) -> None:
dual_harness.drain_sync()
@@ -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"]
+433
View File
@@ -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
+47
View File
@@ -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
+304
View File
@@ -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
+14
View File
@@ -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()
+13
View File
@@ -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()