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