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

434 lines
15 KiB
Python

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