Files
site/WESP_REL/tests/e2e/test_recipes_page.py
2026-07-17 12:57:18 +03:00

305 lines
12 KiB
Python

"""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)