434 lines
15 KiB
Python
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
|