"""Общие хелперы 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