Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+54
View File
@@ -0,0 +1,54 @@
"""Общие хуки pytest для WESP."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
_FIXTURES = Path(__file__).resolve().parent / "fixtures"
_WEAK_CREDENTIALS_FILE = _FIXTURES / "weak_credentials.txt"
_HARDWARE_IGNORE = {
"tests/test_gpio_controller.py",
"tests/test_hx711_wrapper.py",
}
def pytest_collection_modifyitems(config, items):
"""Дублирует ignore из pytest.ini при явном указании путей."""
ignored: list[pytest.Item] = []
kept: list[pytest.Item] = []
for item in items:
rel = item.nodeid.split("::", 1)[0].replace("\\", "/")
if rel in _HARDWARE_IGNORE:
ignored.append(item)
else:
kept.append(item)
# Убираем GPIO/HX711 только при смешанном прогоне (например «all»), не при явном выборе набора.
if ignored and kept:
items[:] = kept
def pytest_configure(config):
"""Минимальный список слабых логинов/паролей для тестов валидации admin/setup."""
os.environ.setdefault(
"WESP_WEAK_CREDENTIALS_BUILTIN_PATH",
str(_WEAK_CREDENTIALS_FILE),
)
from app.services.weak_password_blocklist import reload_weak_blocklist_for_tests
reload_weak_blocklist_for_tests()
@pytest.fixture(autouse=True)
def _reset_global_auto_updater():
"""Глобальный update.auto_updater не должен перетекать между unittest-приложениями."""
yield
try:
from app.services.auto_update_runtime import stop_update_checker
stop_update_checker()
except Exception:
pass
+1
View File
@@ -0,0 +1 @@
"""Браузерные E2E-тесты WESP."""
+343
View File
@@ -0,0 +1,343 @@
"""Фикстуры Playwright E2E: live_server, auth, seed данных /recipes и zootech."""
from __future__ import annotations
import os
import socket
import tempfile
import threading
from typing import Generator, Tuple
import pytest
from werkzeug.serving import make_server
pytest.importorskip("playwright")
from app import create_app, db
from app.models import Component, FeedDispenser, Ingredient, Recipe, UnloadingGroup
from app.services.setup_state import mark_setup_complete
from config import TestingConfig
from tests.helpers.dispenser_recipe_fixtures import (
E2E_DISP_ID,
E2E_DISP_RECIPE_1,
E2E_DISP_RECIPE_2,
E2E_PERIOD_A,
E2E_PERIOD_B,
reset_e2e_dispenser_period_state,
seed_dispenser_period_recipes,
)
E2E_MILL_ID = "e2e-mill-1"
E2E_RECIPE_ID = "e2e-recipe-1"
E2E_COMP_A = "e2e-comp-a"
E2E_COMP_B = "e2e-comp-b"
E2E_ING_A = "e2e-ing-a"
E2E_ING_B = "e2e-ing-b"
E2E_LOGIN = "recipes-e2e-admin"
E2E_PASSWORD = "recipes-e2e-secret"
class RecipesE2EConfig(TestingConfig):
_TMP_DIR = tempfile.mkdtemp(prefix="wesp-recipes-e2e-")
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(_TMP_DIR, 'recipes_e2e.db')}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(_TMP_DIR, 'reports_e2e.db')}"}
AUTH_LOGIN = E2E_LOGIN
AUTH_PASSWORD = E2E_PASSWORD
TESTING = True
def _free_port() -> int:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
def _seed_e2e_recipes_data() -> None:
db.session.add_all(
[
FeedDispenser(
id=E2E_MILL_ID,
name="E2E Кормоцех",
farm="Ферма",
operator="Тест",
type="mill",
content_hash="",
),
Component(
id=E2E_COMP_A,
name="E2E Компонент A",
type="grain",
dry_matter=50.0,
protein=0.0,
energy=0.0,
price=0.0,
),
Component(
id=E2E_COMP_B,
name="E2E Компонент B",
type="grain",
dry_matter=60.0,
protein=0.0,
energy=0.0,
price=0.0,
),
Recipe(
id=E2E_RECIPE_ID,
name="E2E Два компонента",
heads_per_trip=10,
mixing_time=5,
trip_percent=100.0,
target_component_id=E2E_COMP_A,
content_hash="",
),
Ingredient(
id=E2E_ING_A,
name="Ing A",
weight_per_head=1.0,
amount=10.0,
dry_matter=50.0,
component_id=E2E_COMP_A,
order=1,
recipe_id=E2E_RECIPE_ID,
created_by="system",
updated_by="system",
),
Ingredient(
id=E2E_ING_B,
name="Ing B",
weight_per_head=2.0,
amount=20.0,
dry_matter=60.0,
component_id=E2E_COMP_B,
order=2,
recipe_id=E2E_RECIPE_ID,
created_by="system",
updated_by="system",
),
UnloadingGroup(
id="e2e-grp-1",
name="E2E Группа",
distribution_type="percent",
value=100.0,
weight=30.0,
order=1,
recipe_id=E2E_RECIPE_ID,
created_by="system",
updated_by="system",
),
]
)
db.session.commit()
@pytest.fixture(autouse=True)
def _reset_e2e_dispenser_state(e2e_app) -> None:
with e2e_app.app_context():
reset_e2e_dispenser_period_state()
for rid, default_name in (
(E2E_RECIPE_ID, "E2E Два компонента"),
(E2E_DISP_RECIPE_1, "E2E Рейс 1"),
(E2E_DISP_RECIPE_2, "E2E Рейс 2"),
):
recipe = db.session.get(Recipe, rid)
if recipe is not None:
recipe.name = default_name
recipe.unloading_link_broken = False
db.session.commit()
@pytest.fixture(scope="session")
def e2e_app():
app = create_app(RecipesE2EConfig)
with app.app_context():
db.create_all()
mark_setup_complete(app)
_seed_e2e_recipes_data()
seed_dispenser_period_recipes()
yield app
with app.app_context():
db.session.remove()
db.drop_all()
@pytest.fixture(scope="session")
def live_server_url(e2e_app) -> Generator[str, None, None]:
port = _free_port()
server = make_server("127.0.0.1", port, e2e_app, threaded=True)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{port}"
server.shutdown()
@pytest.fixture
def zootech_authenticated_page(page, live_server_url):
"""Залогиненная страница для zootech-страниц (не только /recipes)."""
resp = page.request.post(
f"{live_server_url}/api/auth/login",
headers={"Content-Type": "application/json"},
data='{"login":"%s","password":"%s"}' % (E2E_LOGIN, E2E_PASSWORD),
)
assert resp.ok, resp.text()
return page
@pytest.fixture
def authenticated_page(page, live_server_url):
"""Страница с сессией зоотехника после POST /api/auth/login."""
resp = page.request.post(
f"{live_server_url}/api/auth/login",
headers={"Content-Type": "application/json"},
data='{"login":"%s","password":"%s"}' % (E2E_LOGIN, E2E_PASSWORD),
)
assert resp.ok, resp.text()
page.goto(f"{live_server_url}/recipes")
page.wait_for_selector("#recipesDashboard", state="visible", timeout=15000)
return page
@pytest.fixture
def mill_recipe_open(authenticated_page, live_server_url):
"""Открытый редактор рецепта кормоцеха с двумя ингредиентами."""
page = authenticated_page
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
recipe_sel = f'[data-action="select-recipe"][data-recipe-id="{E2E_RECIPE_ID}"]'
page.wait_for_selector(recipe_sel, timeout=10000)
page.locator(recipe_sel).click()
page.wait_for_selector("#recipeEdit", state="visible", timeout=15000)
page.wait_for_selector("#ingredientsTableBody .zt-data-grid__row", timeout=10000)
return page
@pytest.fixture
def dispenser_dashboard_open(authenticated_page):
"""Dashboard кормораздатчика: выбран dispenser, виден period."""
page = authenticated_page
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_DISP_ID}"]').click()
page.wait_for_selector(
f'[data-action="select-period"][data-period-id="{E2E_PERIOD_A}"]', timeout=10000
)
return page
@pytest.fixture
def dispenser_recipe_open(dispenser_dashboard_open):
"""Редактор рецепта кормораздатчика в period A."""
page = dispenser_dashboard_open
page.locator(f'[data-action="select-period"][data-period-id="{E2E_PERIOD_A}"]').click()
recipe_sel = f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]'
page.wait_for_selector(recipe_sel, timeout=10000)
page.locator(recipe_sel).click()
page.wait_for_selector("#recipeEdit", state="visible", timeout=15000)
page.wait_for_selector("#unloadingGroupsTableBody .unloading-group-row", timeout=10000)
return page
@pytest.fixture
def two_periods_setup(dispenser_dashboard_open):
"""Dispenser с двумя периодами — period A выбран, список рейсов виден."""
page = dispenser_dashboard_open
page.locator(f'[data-action="select-period"][data-period-id="{E2E_PERIOD_A}"]').click()
page.wait_for_selector(
f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]', timeout=10000
)
return page
# --- Dual-terminal E2E (SyncDualInstanceHarness + два Werkzeug-сервера) ---
from tests.helpers.sync_dual_harness import SyncDualInstanceHarness
def _dual_free_port() -> int:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
def _dual_start_server(app, port: int):
server = make_server("127.0.0.1", port, app, threaded=True)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server
def _dual_login_page(page, base_url: str) -> None:
login = SyncDualInstanceHarness.AUTH_LOGIN
password = SyncDualInstanceHarness.AUTH_PASSWORD
resp = page.request.post(
f"{base_url}/api/auth/login",
headers={"Content-Type": "application/json"},
data='{"login":"%s","password":"%s"}' % (login, password),
)
assert resp.ok, resp.text()
@pytest.fixture(scope="module")
def dual_harness() -> Generator[SyncDualInstanceHarness, None, None]:
harness = SyncDualInstanceHarness()
harness.start()
disp_id, period_a, period_b, r1, r2 = harness.seed_dispenser_two_periods_two_recipes()
harness._e2e_disp_id = disp_id # type: ignore[attr-defined]
harness._e2e_period_a = period_a # type: ignore[attr-defined]
harness._e2e_period_b = period_b # type: ignore[attr-defined]
harness._e2e_r1 = r1 # type: ignore[attr-defined]
harness._e2e_r2 = r2 # type: ignore[attr-defined]
yield harness
harness.stop()
@pytest.fixture(scope="module")
def live_server_a_url(dual_harness) -> Generator[str, None, None]:
port = _dual_free_port()
server = _dual_start_server(dual_harness.term_a_app, port)
yield f"http://127.0.0.1:{port}"
server.shutdown()
@pytest.fixture(scope="module")
def live_server_b_url(dual_harness) -> Generator[str, None, None]:
port = _dual_free_port()
server = _dual_start_server(dual_harness.term_b_app, port)
yield f"http://127.0.0.1:{port}"
server.shutdown()
@pytest.fixture
def page_a(browser, live_server_a_url) -> Generator:
context = browser.new_context(viewport={"width": 1280, "height": 800})
page = context.new_page()
_dual_login_page(page, live_server_a_url)
page.goto(f"{live_server_a_url}/recipes")
page.wait_for_selector("#recipesDashboard", timeout=15000)
yield page
context.close()
@pytest.fixture
def page_b(browser, live_server_b_url) -> Generator:
context = browser.new_context(viewport={"width": 1280, "height": 800})
page = context.new_page()
_dual_login_page(page, live_server_b_url)
page.goto(f"{live_server_b_url}/recipes")
page.wait_for_selector("#recipesDashboard", timeout=15000)
yield page
context.close()
@pytest.fixture
def dual_dispenser_period_a(page_a, dual_harness, live_server_a_url):
disp_id = dual_harness._e2e_disp_id # type: ignore[attr-defined]
period_a = dual_harness._e2e_period_a # type: ignore[attr-defined]
page_a.locator(f'[data-action="select-dispenser"][data-dispenser-id="{disp_id}"]').click()
page_a.locator(f'[data-action="select-period"][data-period-id="{period_a}"]').click()
page_a.wait_for_selector("#recipesList .list-item", timeout=10000)
return page_a, dual_harness, live_server_a_url, period_a
def sync_both(dual_harness: SyncDualInstanceHarness) -> None:
dual_harness.drain_sync()
@@ -0,0 +1,9 @@
"""Re-export dual-terminal helpers (fixtures live in tests/e2e/conftest.py)."""
from __future__ import annotations
from tests.e2e.conftest import E2E_PERIOD_A, sync_both
DUAL_E2E_PERIOD_A = E2E_PERIOD_A
__all__ = ["DUAL_E2E_PERIOD_A", "sync_both"]
+433
View File
@@ -0,0 +1,433 @@
"""Общие хелперы 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
@@ -0,0 +1,14 @@
"""E2E smoke: /components."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.e2e
def test_components_page_loads(zootech_authenticated_page, live_server_url) -> None:
page = zootech_authenticated_page
page.goto(f"{live_server_url}/components")
page.wait_for_load_state("domcontentloaded")
assert "components" in page.url or page.locator("body").is_visible()
@@ -0,0 +1,135 @@
"""E2E: изменение плана на день → sync → оператор видит план (не мастер)."""
from __future__ import annotations
import pytest
from app import db
from app.models import Recipe
from app.services.daily_plan.replacements import replace_ingredient
from app.services.daily_plan.skips import skip_ingredient
from app.services.setup_state import mark_setup_complete
from datetime import date
from tests.helpers.daily_plan_operator_helpers import (
assert_operator_matches_plan_not_master,
plan_recipe_json,
)
from tests.helpers.dispenser_recipe_fixtures import (
E2E_DISP_COMP,
E2E_DISP_RECIPE_1,
seed_dispenser_period_recipes,
)
from tests.helpers.sync_dual_harness import SyncDualInstanceHarness
pytestmark = pytest.mark.e2e
@pytest.fixture
def daily_plan_sync_harness() -> SyncDualInstanceHarness:
from app.models import Component, Ingredient
harness = SyncDualInstanceHarness()
harness.start()
with harness.server_ctx():
mark_setup_complete(harness.server_app)
seed_dispenser_period_recipes()
db.session.add(
Ingredient(
id="e2e-disp-ing-extra",
name="Ing Extra",
weight_per_head=3.0,
amount=30.0,
dry_matter=55.0,
component_id=E2E_DISP_COMP,
order=2,
recipe_id=E2E_DISP_RECIPE_1,
created_by="system",
updated_by="system",
)
)
db.session.add(
Component(
id="e2e-disp-comp-e2e-alt",
name="E2E Alt Silo",
type="silage",
dry_matter=35.0,
protein=3.0,
energy=6.0,
price=0.0,
)
)
recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1)
recipe.dry_matter_locked = True
db.session.commit()
harness.drain_sync()
yield harness
harness.stop()
def test_e2e_replacement_sync_operator_not_master(daily_plan_sync_harness) -> None:
harness = daily_plan_sync_harness
plan_date = date.today().isoformat()
with harness.server_ctx():
replace_ingredient(
E2E_DISP_RECIPE_1,
"e2e-disp-ing-1",
"e2e-disp-comp-e2e-alt",
plan_date,
)
harness.drain_sync()
kiosk = harness.term_a_app.test_client()
with harness.terminal_ctx("a"):
client_recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1)
client_recipe.dry_matter_locked = True
db.session.commit()
assert_operator_matches_plan_not_master(kiosk, E2E_DISP_RECIPE_1, plan_date=plan_date)
def test_e2e_ingredient_skip_sync_operator_second_component(daily_plan_sync_harness) -> None:
harness = daily_plan_sync_harness
plan_date = date.today().isoformat()
with harness.server_ctx():
skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", plan_date)
harness.drain_sync()
kiosk = harness.term_b_app.test_client()
plan = plan_recipe_json(kiosk, E2E_DISP_RECIPE_1, plan_date=plan_date)
assert len(plan["ingredients"]) == 1
assert plan["ingredients"][0]["id"] == "e2e-disp-ing-extra"
operator = assert_operator_matches_plan_not_master(
kiosk,
E2E_DISP_RECIPE_1,
plan_date=plan_date,
expect_plan_differs_from_master=True,
)
assert operator["operator"]["total_component"] == int(round(operator["plan_amounts"][0]))
def test_e2e_zootech_api_change_reaches_terminal_operator(daily_plan_sync_harness) -> None:
"""Зоотех на сервере POST /api/daily-plan → sync → терминал оператора."""
harness = daily_plan_sync_harness
plan_date = date.today().isoformat()
server = harness.server_test_client()
resp = server.post(
"/api/daily-plan/replacements/ingredients",
json={
"recipeId": E2E_DISP_RECIPE_1,
"ingredientId": "e2e-disp-ing-1",
"replacementComponentId": "e2e-disp-comp-e2e-alt",
"date": plan_date,
},
)
assert resp.status_code == 200, resp.text
harness.drain_sync()
kiosk = harness.term_a_app.test_client()
with harness.terminal_ctx("a"):
client_recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1)
client_recipe.dry_matter_locked = True
db.session.commit()
assert_operator_matches_plan_not_master(kiosk, E2E_DISP_RECIPE_1, plan_date=plan_date)
@@ -0,0 +1,14 @@
"""E2E smoke: /feed_consumption."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.e2e
def test_feed_consumption_page_loads(zootech_authenticated_page, live_server_url) -> None:
page = zootech_authenticated_page
page.goto(f"{live_server_url}/feed_consumption")
page.wait_for_load_state("domcontentloaded")
assert page.locator("body").is_visible()
@@ -0,0 +1,14 @@
"""E2E smoke: /feed_dispensers."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.e2e
def test_feed_dispensers_page_loads(zootech_authenticated_page, live_server_url) -> None:
page = zootech_authenticated_page
page.goto(f"{live_server_url}/feed_dispensers")
page.wait_for_load_state("networkidle")
assert page.locator("body").is_visible()
@@ -0,0 +1,302 @@
"""Playwright E2E: кормораздатчик — dashboard, copy/paste, unlink delete, move."""
from __future__ import annotations
import pytest
from tests.e2e.conftest import (
E2E_DISP_ID,
E2E_DISP_RECIPE_1,
E2E_DISP_RECIPE_2,
E2E_PERIOD_A,
E2E_PERIOD_B,
)
from tests.e2e.recipes_e2e_helpers import (
drag_reorder_recipe,
fetch_recipe,
list_dom_recipe_ids,
period_recipe_ids,
pointer_drag_reorder_recipe,
)
pytestmark = pytest.mark.e2e
SCENARIO_E2E_DRAG_REORDER = "E2E_DRAG_REORDER"
SCENARIO_E2E_MOVE_RECIPE_UP = "E2E_MOVE_RECIPE_UP"
SCENARIO_E2E_EDITOR_UNLOADING_GROUP = "E2E_EDITOR_UNLOADING_GROUP"
SCENARIO_E2E_EDITOR_CANCEL = "E2E_EDITOR_CANCEL"
def test_dispenser_dashboard_three_columns(authenticated_page) -> None:
page = authenticated_page
assert page.locator("#dispensersCol").is_visible()
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_DISP_ID}"]').click()
page.wait_for_selector(
f'[data-action="select-period"][data-period-id="{E2E_PERIOD_A}"]', timeout=10000
)
assert page.locator("#periodsCol").is_visible()
assert page.locator("#recipesCol").count() == 1
def test_select_period_shows_recipes(two_periods_setup) -> None:
page = two_periods_setup
assert page.locator(
f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]'
).is_visible()
assert page.locator(
f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_2}"]'
).is_visible()
def test_create_recipe_opens_editor(two_periods_setup) -> None:
page = two_periods_setup
create_btn = page.locator('[data-action="create-recipe"]').first
create_btn.click()
page.wait_for_selector("#recipeEdit", state="visible", timeout=15000)
assert page.locator("#recipeName").is_visible()
def test_copy_paste_recipe_between_periods(two_periods_setup, live_server_url) -> None:
page = two_periods_setup
with page.expect_response(
lambda r: r.request.method == "GET" and f"/api/recipes/{E2E_DISP_RECIPE_1}" in r.url,
timeout=15000,
):
page.locator(
f'[data-action="copy-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]'
).first.click()
page.locator(f'[data-action="select-period"][data-period-id="{E2E_PERIOD_B}"]').click()
paste = page.locator(
f'[data-action="paste-recipe"][data-period-id="{E2E_PERIOD_B}"]'
)
page.wait_for_timeout(400)
assert not paste.is_disabled(), "paste должен быть активен после copy"
paste.click()
page.wait_for_selector("#recipeEdit", state="visible", timeout=15000)
assert "(копия)" in page.locator("#recipeName").input_value()
group_rows = page.locator("#unloadingGroupsTableBody .unloading-group-row")
page.wait_for_timeout(500)
assert group_rows.count() >= 1, "после paste должна остаться хотя бы одна группа выгрузки"
page.locator('[data-action="save-and-exit"]').click()
page.wait_for_selector("#recipeEdit", state="hidden", timeout=15000)
listed = page.request.get(f"{live_server_url}/api/periods/{E2E_PERIOD_B}/recipes")
assert listed.ok
new_ids = [r["id"] for r in listed.json() if r["id"] != E2E_DISP_RECIPE_1]
assert new_ids
pasted = fetch_recipe(page, live_server_url, new_ids[0])
groups = pasted.get("unloading_groups") or []
assert groups
assert groups[0].get("distribution_type") in ("heads", "percent")
assert float(groups[0].get("value") or 0) > 0
def test_heads_change_recalculates_group_weights(dispenser_recipe_open) -> None:
SCENARIO_E2E_HEADS_RECALC = "E2E_HEADS_RECALC" # noqa: F841
page = dispenser_recipe_open
type_select = page.locator(
"#unloadingGroupsTableBody select[data-action='group-type-change']"
).first
if type_select.count():
type_select.select_option("heads")
page.wait_for_timeout(400)
weight_input = page.locator("#unloadingGroupsTableBody .group-weight").first
page.wait_for_timeout(800)
before = float(weight_input.input_value() or 0)
with page.expect_response(
lambda r: "/api/recipes/calculate" in r.url and r.request.method == "POST",
timeout=15000,
):
page.locator("#headsCount").fill("5")
page.locator("#headsCount").dispatch_event("input")
page.locator("#headsCount").dispatch_event("change")
page.wait_for_timeout(500)
after = float(weight_input.input_value() or 0)
assert before > 0
assert after != before
def test_move_recipe_down_changes_dom_order(two_periods_setup) -> None:
page = two_periods_setup
items = page.locator("#recipesList .list-item[data-recipe-id]")
before = [items.nth(i).get_attribute("data-recipe-id") for i in range(items.count())]
assert before == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
with page.expect_response(
lambda r: r.request.method == "PUT" and "/move" in r.url,
timeout=15000,
):
page.locator(
f'[data-action="move-recipe-down"][data-recipe-id="{E2E_DISP_RECIPE_1}"]'
).click()
page.wait_for_timeout(600)
after = [items.nth(i).get_attribute("data-recipe-id") for i in range(items.count())]
assert after == [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
def test_dispenser_editor_shows_ingredients(dispenser_recipe_open) -> None:
page = dispenser_recipe_open
rows = page.locator("#ingredientsTableBody .ingredient-row")
assert rows.count() >= 1
def test_move_recipe_up_button_present(two_periods_setup) -> None:
page = two_periods_setup
assert page.locator('[data-action="move-recipe-up"]').count() >= 1
assert page.locator('[data-action="move-recipe-down"]').count() >= 1
def test_recipe_drag_handle_present(two_periods_setup) -> None:
page = two_periods_setup
assert page.locator('[data-action="recipe-drag-handle"]').count() >= 1
def test_delete_recipe_unlinks_from_period(two_periods_setup, live_server_url) -> None:
page = two_periods_setup
page.on("dialog", lambda d: d.accept())
with page.expect_response(
lambda r: r.request.method == "DELETE"
and f"/periods/{E2E_PERIOD_A}/recipes/" in r.url,
timeout=15000,
):
page.locator(
f'[data-action="delete-recipe"][data-recipe-id="{E2E_DISP_RECIPE_2}"]'
).click()
page.wait_for_timeout(500)
assert (
page.locator(
f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_2}"]'
).count()
== 0
)
resp = page.request.get(f"{live_server_url}/api/recipes/{E2E_DISP_RECIPE_2}")
assert resp.ok
def test_drag_reorder_swaps_recipes_and_api_order(two_periods_setup, live_server_url) -> None:
SCENARIO_E2E_DRAG_REORDER # noqa: F841
page = two_periods_setup
page.set_viewport_size({"width": 1280, "height": 800})
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
with page.expect_response(
lambda r: r.request.method == "PUT" and "/move" in r.url,
timeout=15000,
):
drag_reorder_recipe(page, E2E_DISP_RECIPE_1, direction="down")
page.wait_for_timeout(500)
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_A) == [
E2E_DISP_RECIPE_2,
E2E_DISP_RECIPE_1,
]
def test_mouse_drag_reorder_works_twice_in_a_row(two_periods_setup) -> None:
page = two_periods_setup
page.set_viewport_size({"width": 1280, "height": 800})
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
drag_reorder_recipe(page, E2E_DISP_RECIPE_1, direction="down")
page.wait_for_timeout(400)
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
drag_reorder_recipe(
page, E2E_DISP_RECIPE_1, direction="up", expect_move_response=False
)
page.wait_for_timeout(400)
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
def test_pointer_drag_reorder_works_twice_in_a_row(two_periods_setup) -> None:
page = two_periods_setup
page.set_viewport_size({"width": 1280, "height": 800})
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
first = pointer_drag_reorder_recipe(page, E2E_DISP_RECIPE_1, direction="down")
assert first.get("ok"), first
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
page.wait_for_timeout(400)
second = pointer_drag_reorder_recipe(page, E2E_DISP_RECIPE_1, direction="up")
assert second.get("ok"), second
assert second.get("reorderActive"), second
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
def test_pointer_drag_reorder_swaps_dom_order(two_periods_setup) -> None:
"""Pointer-path: ловит баг, когда transfer endSession рвёт активный reorder."""
page = two_periods_setup
page.set_viewport_size({"width": 1280, "height": 800})
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
result = pointer_drag_reorder_recipe(page, E2E_DISP_RECIPE_1, direction="down")
assert result.get("ok"), result
assert result.get("reorderActive"), "pointer reorder should activate"
assert result.get("idsAfter") == [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1], result
def test_move_recipe_up_changes_api_order(two_periods_setup, live_server_url) -> None:
SCENARIO_E2E_MOVE_RECIPE_UP # noqa: F841
page = two_periods_setup
page.locator(
f'[data-action="move-recipe-down"][data-recipe-id="{E2E_DISP_RECIPE_1}"]'
).click()
page.wait_for_timeout(500)
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_A) == [
E2E_DISP_RECIPE_2,
E2E_DISP_RECIPE_1,
]
with page.expect_response(
lambda r: r.request.method == "PUT" and "/move" in r.url,
timeout=15000,
):
page.locator(
f'[data-action="move-recipe-up"][data-recipe-id="{E2E_DISP_RECIPE_1}"]'
).click()
page.wait_for_timeout(500)
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_A) == [
E2E_DISP_RECIPE_1,
E2E_DISP_RECIPE_2,
]
def test_editor_add_remove_unloading_group(dispenser_recipe_open) -> None:
SCENARIO_E2E_EDITOR_UNLOADING_GROUP # noqa: F841
page = dispenser_recipe_open
rows = page.locator("#unloadingGroupsTableBody .unloading-group-row")
before = rows.count()
assert before >= 1
page.locator('[data-action="add-unloading-group"]').click()
page.wait_for_timeout(300)
assert rows.count() == before + 1
page.locator('[data-action="remove-unloading-group"]').last.click()
page.wait_for_timeout(200)
assert rows.count() == before
def test_editor_cancel_discards_unsaved_name(dispenser_recipe_open) -> None:
SCENARIO_E2E_EDITOR_CANCEL # noqa: F841
page = dispenser_recipe_open
original = page.locator("#recipeName").input_value()
page.locator("#recipeName").fill("E2E Не должно сохраниться")
page.locator('[data-action="show-recipe-edit-off"]').first.click()
page.wait_for_timeout(400)
assert page.locator("#recipeEdit").is_hidden() or not page.locator("#recipeEdit").is_visible()
page.locator(f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]').click()
page.wait_for_selector("#recipeEdit", state="visible", timeout=15000)
assert page.locator("#recipeName").input_value() == original
def test_editor_group_value_change_updates_input(dispenser_recipe_open) -> None:
page = dispenser_recipe_open
inp = page.locator(
"#unloadingGroupsTableBody .unloading-group-row [data-action='group-value-change']"
).first
assert inp.is_visible()
inp.fill("77.5")
assert inp.input_value() == "77.5"
@@ -0,0 +1,159 @@
"""E2E: два терминала — DnD/sync (баготест #4)."""
from __future__ import annotations
import pytest
from tests.e2e.recipes_e2e_helpers import (
drag_reorder_recipe,
list_dom_recipe_ids,
period_recipe_ids,
)
pytestmark = pytest.mark.e2e
SCENARIO_DUAL_E2E_DND_SYNC = "DUAL_E2E_DND_SYNC"
SCENARIO_DUAL_E2E_TRANSFER_REORDER = "DUAL_E2E_TRANSFER_REORDER"
def test_dual_reorder_visible_on_both_terminals(
page_a, page_b, dual_harness, live_server_a_url, live_server_b_url
) -> None:
SCENARIO_DUAL_E2E_DND_SYNC # noqa: F841
disp_id = dual_harness._e2e_disp_id # type: ignore[attr-defined]
period_a = dual_harness._e2e_period_a # type: ignore[attr-defined]
r1 = dual_harness._e2e_r1 # type: ignore[attr-defined]
r2 = dual_harness._e2e_r2 # type: ignore[attr-defined]
for page, url in ((page_a, live_server_a_url), (page_b, live_server_b_url)):
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{disp_id}"]').click()
page.locator(f'[data-action="select-period"][data-period-id="{period_a}"]').click()
page.wait_for_selector("#recipesList .list-item", timeout=10000)
assert list_dom_recipe_ids(page_a) == [r1, r2]
assert list_dom_recipe_ids(page_b) == [r1, r2]
drag_reorder_recipe(page_a, r1, direction="down")
page_a.wait_for_timeout(600)
from tests.e2e.dual_terminal_conftest import sync_both
sync_both(dual_harness)
page_b.reload()
page_b.wait_for_selector("#recipesDashboard", timeout=15000)
page_b.locator(f'[data-action="select-dispenser"][data-dispenser-id="{disp_id}"]').click()
page_b.locator(f'[data-action="select-period"][data-period-id="{period_a}"]').click()
page_b.wait_for_timeout(500)
assert period_recipe_ids(page_a, live_server_a_url, period_a) == [r2, r1]
assert period_recipe_ids(page_b, live_server_b_url, period_a) == [r2, r1]
def test_dual_edit_recipe_name_both_see(
page_a, page_b, dual_harness, live_server_a_url, live_server_b_url
) -> None:
r1 = dual_harness._e2e_r1 # type: ignore[attr-defined]
current = page_a.request.get(f"{live_server_a_url}/api/recipes/{r1}")
assert current.ok
payload = current.json()
payload["name"] = "Dual renamed"
payload["ingredients"] = payload.get("ingredients") or []
payload["unloading_groups"] = payload.get("unloading_groups") or []
put = page_a.request.put(f"{live_server_a_url}/api/recipes/{r1}", data=payload)
assert put.ok, put.text()
dual_harness.push_from_terminal("a")
with dual_harness.server_ctx():
from app import db
from app.models import Recipe
from app.services.sync_manager import enqueue_sync_queue_task
row = db.session.get(Recipe, r1)
if row is not None:
row.name = "Dual renamed"
db.session.commit()
enqueue_sync_queue_task("recipe", r1, "update", priority=2)
db.session.commit()
from tests.e2e.dual_terminal_conftest import sync_both
sync_both(dual_harness)
for page, url in ((page_a, live_server_a_url), (page_b, live_server_b_url)):
resp = page.request.get(f"{url}/api/recipes/{r1}")
assert resp.ok
assert resp.json().get("name") == "Dual renamed"
def test_dual_copy_paste_unloading_groups(
page_a, dual_harness, live_server_a_url, live_server_b_url, page_b
) -> None:
"""После paste на A sync — B видит рецепт в period B (API)."""
period_b = dual_harness._e2e_period_b # type: ignore[attr-defined]
r1 = dual_harness._e2e_r1 # type: ignore[attr-defined]
src = page_a.request.get(f"{live_server_a_url}/api/recipes/{r1}")
assert src.ok
body = src.json()
body["name"] = f"{body.get('name', 'R')} (копия)"
body["ingredients"] = body.get("ingredients") or []
body["unloading_groups"] = body.get("unloading_groups") or [
{"name": "G1", "distribution_type": "percent", "value": 100, "order": 1}
]
create = page_a.request.post(
f"{live_server_a_url}/api/periods/{period_b}/recipes",
data=body,
)
assert create.ok or create.status in (200, 201), create.text()
new_id = create.json().get("id") if create.ok else None
dual_harness.push_from_terminal("a")
if new_id:
with dual_harness.server_ctx():
from app.services.sync_manager import enqueue_sync_queue_task
enqueue_sync_queue_task("recipe", new_id, "create", priority=2)
enqueue_sync_queue_task(
"period_recipes", f"{period_b}:{new_id}", "create", priority=2
)
from app import db
db.session.commit()
from tests.e2e.dual_terminal_conftest import sync_both
sync_both(dual_harness)
listed = page_b.request.get(f"{live_server_b_url}/api/periods/{period_b}/recipes")
assert listed.ok
assert len(listed.json()) >= 1
rid = new_id or listed.json()[0]["id"]
detail = page_b.request.get(f"{live_server_b_url}/api/recipes/{rid}")
assert detail.ok
groups = detail.json().get("unloading_groups") or []
assert groups
def test_dual_transfer_then_reorder_on_b(
page_a, page_b, dual_harness, live_server_a_url, live_server_b_url
) -> None:
SCENARIO_DUAL_E2E_TRANSFER_REORDER # noqa: F841
disp_id = dual_harness._e2e_disp_id # type: ignore[attr-defined]
period_a = dual_harness._e2e_period_a # type: ignore[attr-defined]
period_b = dual_harness._e2e_period_b # type: ignore[attr-defined]
r2 = dual_harness._e2e_r2 # type: ignore[attr-defined]
from tests.e2e.recipes_e2e_helpers import drag_transfer_to_period
page_a.locator(f'[data-action="select-dispenser"][data-dispenser-id="{disp_id}"]').click()
page_a.locator(f'[data-action="select-period"][data-period-id="{period_a}"]').click()
drag_transfer_to_period(page_a, r2, period_b)
from tests.e2e.dual_terminal_conftest import sync_both
sync_both(dual_harness)
page_b.locator(f'[data-action="select-dispenser"][data-dispenser-id="{disp_id}"]').click()
page_b.locator(f'[data-action="select-period"][data-period-id="{period_b}"]').click()
ids_b = period_recipe_ids(page_b, live_server_b_url, period_b)
assert r2 in ids_b
+47
View File
@@ -0,0 +1,47 @@
"""Playwright E2E: mobile viewport /recipes — dashboard steps, dispenser flow."""
from __future__ import annotations
import pytest
from tests.e2e.conftest import E2E_DISP_ID, E2E_DISP_RECIPE_1, E2E_PERIOD_A
pytestmark = pytest.mark.e2e
MOBILE_VIEWPORT = {"width": 390, "height": 844}
def test_mobile_dispenser_period_recipe_steps(authenticated_page) -> None:
page = authenticated_page
page.set_viewport_size(MOBILE_VIEWPORT)
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_DISP_ID}"]').click()
page.wait_for_timeout(400)
assert page.evaluate(
"() => document.body.classList.contains('dashboard-mobile-step-periods')"
)
page.locator(f'[data-action="select-period"][data-period-id="{E2E_PERIOD_A}"]').click()
page.wait_for_selector(
f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]', timeout=10000
)
assert page.evaluate(
"() => document.body.classList.contains('dashboard-mobile-step-recipes')"
)
def test_mobile_back_from_recipes_to_periods(authenticated_page) -> None:
page = authenticated_page
page.set_viewport_size(MOBILE_VIEWPORT)
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_DISP_ID}"]').click()
page.locator(f'[data-action="select-period"][data-period-id="{E2E_PERIOD_A}"]').click()
page.wait_for_selector('[data-action="mobile-dashboard-back"]', timeout=10000)
page.locator('[data-action="mobile-dashboard-back"]').click()
page.wait_for_timeout(400)
assert page.evaluate(
"() => document.body.classList.contains('dashboard-mobile-step-periods')"
)
def test_mobile_drag_handles_present_on_recipe_list(two_periods_setup) -> None:
page = two_periods_setup
page.set_viewport_size(MOBILE_VIEWPORT)
assert page.locator('[data-action="recipe-drag-handle"]').count() >= 2
+304
View File
@@ -0,0 +1,304 @@
"""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)
@@ -0,0 +1,170 @@
"""Playwright E2E: drag-transfer рейса между периодами."""
from __future__ import annotations
import pytest
from tests.e2e.conftest import (
E2E_DISP_ID,
E2E_DISP_RECIPE_1,
E2E_DISP_RECIPE_2,
E2E_PERIOD_A,
E2E_PERIOD_B,
)
from tests.e2e.recipes_e2e_helpers import (
LONG_HOVER_MS,
drag_reorder_recipe,
drag_transfer_to_period,
list_dom_recipe_ids,
period_recipe_ids,
)
pytestmark = pytest.mark.e2e
SCENARIO_E2E_TRANSFER_ESCAPE = "E2E_TRANSFER_ESCAPE"
SCENARIO_E2E_TRANSFER_REPEAT = "E2E_TRANSFER_REPEAT"
SCENARIO_E2E_REORDER_THEN_TRANSFER = "E2E_REORDER_THEN_TRANSFER"
SCENARIO_E2E_TRANSFER_THEN_REORDER = "E2E_TRANSFER_THEN_REORDER"
def _open_period_a(page, recipe_id: str = E2E_DISP_RECIPE_1) -> None:
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_DISP_ID}"]').click()
page.locator(f'[data-action="select-period"][data-period-id="{E2E_PERIOD_A}"]').click()
page.wait_for_selector(
f'[data-action="select-recipe"][data-recipe-id="{recipe_id}"]', timeout=10000
)
def test_transfer_escape_cancels_without_api_call(authenticated_page) -> None:
SCENARIO_E2E_TRANSFER_ESCAPE # noqa: F841
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
_open_period_a(page)
handle = page.locator(
f'#recipesList .list-item[data-recipe-id="{E2E_DISP_RECIPE_1}"] [data-action="recipe-drag-handle"]'
).first
box = handle.bounding_box()
assert box
page.mouse.move(box["x"] + 5, box["y"] + 5)
page.mouse.down()
page.mouse.move(box["x"] + 200, box["y"] + 200, steps=8)
page.keyboard.press("Escape")
page.mouse.up()
assert page.locator("button.recipe-transfer-slot").count() == 0
def test_transfer_drag_to_other_period_inserts_recipe(authenticated_page, live_server_url) -> None:
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
_open_period_a(page)
handle = page.locator(
f'#recipesList .list-item[data-recipe-id="{E2E_DISP_RECIPE_1}"] [data-action="recipe-drag-handle"]'
).first
period_b = page.locator(f'[data-action="select-period"][data-period-id="{E2E_PERIOD_B}"]')
handle_box = handle.bounding_box()
period_box = period_b.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()
slot = page.locator("button.recipe-transfer-slot")
page.wait_for_selector("button.recipe-transfer-slot", timeout=10000)
with page.expect_response(
lambda r: "/transfer" in r.url and r.request.method == "POST",
timeout=15000,
):
slot.first.click()
page.wait_for_timeout(500)
listed = page.request.get(f"{live_server_url}/api/periods/{E2E_PERIOD_B}/recipes")
assert listed.ok
ids = [r["id"] for r in listed.json()]
assert E2E_DISP_RECIPE_1 in ids
def test_transfer_repeat_a_b_a(authenticated_page, live_server_url) -> None:
SCENARIO_E2E_TRANSFER_REPEAT # noqa: F841
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
_open_period_a(page)
drag_transfer_to_period(page, E2E_DISP_RECIPE_1, E2E_PERIOD_B)
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_A) == [E2E_DISP_RECIPE_2]
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_B) == [E2E_DISP_RECIPE_1]
page.locator(f'[data-action="select-period"][data-period-id="{E2E_PERIOD_B}"]').click()
page.wait_for_selector(
f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]', timeout=10000
)
drag_transfer_to_period(page, E2E_DISP_RECIPE_1, E2E_PERIOD_A, slot_index=1)
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_A) == [
E2E_DISP_RECIPE_2,
E2E_DISP_RECIPE_1,
]
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_B) == []
def test_transfer_slot_index_one_when_target_has_recipe(
authenticated_page, live_server_url
) -> None:
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
_open_period_a(page)
drag_transfer_to_period(page, E2E_DISP_RECIPE_2, E2E_PERIOD_B)
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_B) == [E2E_DISP_RECIPE_2]
_open_period_a(page, E2E_DISP_RECIPE_1)
drag_transfer_to_period(page, E2E_DISP_RECIPE_1, E2E_PERIOD_B, slot_index=0)
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_B) == [
E2E_DISP_RECIPE_1,
E2E_DISP_RECIPE_2,
]
def test_reorder_then_transfer_still_moves_recipe(authenticated_page, live_server_url) -> None:
SCENARIO_E2E_REORDER_THEN_TRANSFER # noqa: F841
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
_open_period_a(page)
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
with page.expect_response(
lambda r: r.request.method == "PUT" and "/move" in r.url,
timeout=15000,
):
drag_reorder_recipe(page, E2E_DISP_RECIPE_1, direction="down")
page.wait_for_timeout(500)
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
drag_transfer_to_period(page, E2E_DISP_RECIPE_1, E2E_PERIOD_B)
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_A) == [E2E_DISP_RECIPE_2]
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_B) == [E2E_DISP_RECIPE_1]
def test_transfer_then_reorder_on_target_period(authenticated_page, live_server_url) -> None:
SCENARIO_E2E_TRANSFER_THEN_REORDER # noqa: F841
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
_open_period_a(page)
drag_transfer_to_period(page, E2E_DISP_RECIPE_1, E2E_PERIOD_B)
_open_period_a(page, E2E_DISP_RECIPE_2)
drag_transfer_to_period(page, E2E_DISP_RECIPE_2, E2E_PERIOD_B, slot_index=1)
page.locator(f'[data-action="select-period"][data-period-id="{E2E_PERIOD_B}"]').click()
page.wait_for_selector(
f'#recipesList .list-item[data-recipe-id="{E2E_DISP_RECIPE_2}"]', timeout=10000
)
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
drag_reorder_recipe(page, E2E_DISP_RECIPE_2, direction="up", expect_move_response=False)
page.wait_for_timeout(500)
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
assert period_recipe_ids(page, live_server_url, E2E_PERIOD_B) == [
E2E_DISP_RECIPE_2,
E2E_DISP_RECIPE_1,
]
@@ -0,0 +1,63 @@
"""Playwright E2E: настройки /recipes — sync panel, credentials, logout."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.e2e
def test_settings_panel_open_close(authenticated_page) -> None:
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
open_btn = page.locator('[data-action="open-settings"]')
assert open_btn.count() >= 1, "open-settings должен быть в навигации"
open_btn.first.click(force=True)
page.wait_for_selector('[data-action="close-settings"]', timeout=5000)
assert page.locator('[data-action="toggle-sync-form"]').is_visible()
page.locator('#settingsModal [data-action="close-settings"]').first.click()
page.wait_for_timeout(300)
def test_toggle_sync_form(authenticated_page) -> None:
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
page.locator('[data-action="open-settings"]').first.click()
page.wait_for_selector('[data-action="toggle-sync-form"]', timeout=5000)
page.locator('[data-action="toggle-sync-form"]').click()
page.wait_for_timeout(200)
def test_toggle_credentials_form(authenticated_page) -> None:
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
page.locator('[data-action="open-settings"]').first.click(force=True)
cred = page.locator('[data-action="toggle-credentials-form"]')
cred.wait_for(state="visible", timeout=5000)
cred.click()
page.wait_for_timeout(200)
def test_logout_clears_session(authenticated_page, live_server_url) -> None:
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
with page.expect_response(
lambda r: r.url.endswith("/api/auth/logout") and r.request.method == "POST",
timeout=10000,
):
page.locator('[data-action="logout"]').first.click()
page.wait_for_url(f"{live_server_url}/", timeout=10000)
check = page.request.get(f"{live_server_url}/api/auth/check")
assert check.ok
assert check.json().get("authenticated") is False
def test_mobile_dashboard_back(authenticated_page) -> None:
page = authenticated_page
page.set_viewport_size({"width": 390, "height": 844})
from tests.e2e.conftest import E2E_MILL_ID
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)
page.locator('[data-action="mobile-dashboard-back"]').click()
page.wait_for_timeout(400)
@@ -0,0 +1,667 @@
"""E2E user journeys: хаотичные длинные сценарии «полный еблан за терминалом»."""
from __future__ import annotations
import pytest
from tests.e2e.conftest import (
E2E_DISP_ID,
E2E_DISP_RECIPE_1,
E2E_DISP_RECIPE_2,
E2E_MILL_ID,
E2E_PERIOD_A,
E2E_PERIOD_B,
E2E_RECIPE_ID,
)
from tests.e2e.recipes_e2e_helpers import (
abort_transfer_via_escape,
assert_period_order,
click_copy_recipe,
click_move_recipe,
create_recipe_in_period,
delete_recipe_unlink,
drag_reorder_recipe,
drag_transfer_to_period,
ensure_two_recipe_order,
fetch_recipe,
list_dom_recipe_ids,
open_dispenser_period,
period_recipe_ids,
recipe_ingredient_snapshot,
restore_two_recipe_order_if_reversed,
select_period,
wait_editor_closed,
wait_editor_open,
wait_mobile_dashboard_step,
wait_period_order,
)
pytestmark = pytest.mark.e2e
MOBILE_VIEWPORT = {"width": 390, "height": 844}
E2E_DISP_ING_1 = "e2e-disp-ing-1"
SCENARIO_JOURNEY_MORNING_ROUTINE = "JOURNEY_MORNING_ROUTINE"
SCENARIO_JOURNEY_TRANSFER_REPEAT_SLOTS = "JOURNEY_TRANSFER_REPEAT_SLOTS"
SCENARIO_JOURNEY_ESCAPE_REORDER_MIX = "JOURNEY_ESCAPE_REORDER_MIX"
SCENARIO_JOURNEY_MILL_CREATE_DELETE = "JOURNEY_MILL_CREATE_DELETE"
SCENARIO_JOURNEY_SETTINGS_LOGOUT = "JOURNEY_SETTINGS_LOGOUT"
SCENARIO_JOURNEY_EMPTY_THEN_FULL = "JOURNEY_EMPTY_THEN_FULL"
SCENARIO_JOURNEY_MOBILE_FLIP = "JOURNEY_MOBILE_FLIP"
SCENARIO_JOURNEY_EDITOR_CANCEL_REOPEN = "JOURNEY_EDITOR_CANCEL_REOPEN"
SCENARIO_JOURNEY_MOVE_BUTTONS_YOYO = "JOURNEY_MOVE_BUTTONS_YOYO"
SCENARIO_JOURNEY_DOUBLE_COPY_PASTE = "JOURNEY_DOUBLE_COPY_PASTE"
SCENARIO_JOURNEY_MILL_DISPENSER_PINGPONG = "JOURNEY_MILL_DISPENSER_PINGPONG"
SCENARIO_JOURNEY_DISTRACTED_UI = "JOURNEY_DISTRACTED_UI"
SCENARIO_JOURNEY_MEGA_MIX = "JOURNEY_MEGA_MIX"
def _open_dispenser_period_a(page, live_server_url) -> None:
open_dispenser_period(
page,
E2E_PERIOD_A,
wait_recipe_id=E2E_DISP_RECIPE_1,
live_server_url=live_server_url,
expected_order=[E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2],
)
def test_journey_morning_routine_save_reorder_copy_paste_unlink(
authenticated_page, live_server_url
) -> None:
SCENARIO_JOURNEY_MORNING_ROUTINE # noqa: F841
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
_open_dispenser_period_a(page, live_server_url)
page.locator(f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]').click()
wait_editor_open(page)
page.locator("#recipeName").fill("E2E Утренний рейс")
with page.expect_response(
lambda r: r.request.method == "PUT" and E2E_DISP_RECIPE_1 in r.url,
timeout=15000,
):
page.locator('[data-action="save-and-exit"]').click()
wait_editor_closed(page)
assert fetch_recipe(page, live_server_url, E2E_DISP_RECIPE_1)["name"] == "E2E Утренний рейс"
assert_period_order(
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
)
_open_dispenser_period_a(page, live_server_url)
drag_reorder_recipe(
page,
E2E_DISP_RECIPE_1,
direction="down",
live_server_url=live_server_url,
period_id=E2E_PERIOD_A,
expected_order=[E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1],
)
click_copy_recipe(page, E2E_DISP_RECIPE_1)
select_period(page, E2E_PERIOD_B, live_server_url=live_server_url, expected_order=[])
page.locator(f'[data-action="paste-recipe"][data-period-id="{E2E_PERIOD_B}"]').click()
wait_editor_open(page)
with page.expect_response(
lambda r: r.request.method == "POST"
and f"/api/periods/{E2E_PERIOD_B}/recipes" in r.url,
timeout=20000,
) as paste_save:
page.locator('[data-action="save-and-exit"]').click()
pasted_id = paste_save.value.json().get("id")
assert pasted_id
wait_editor_closed(page)
wait_period_order(page, live_server_url, E2E_PERIOD_B, [pasted_id])
select_period(page, E2E_PERIOD_B, wait_recipe_id=pasted_id)
delete_recipe_unlink(
page,
pasted_id,
live_server_url=live_server_url,
period_id=E2E_PERIOD_B,
expected_period_order=[],
)
assert page.request.get(f"{live_server_url}/api/recipes/{pasted_id}").ok
def test_journey_transfer_repeat_with_slots(authenticated_page, live_server_url) -> None:
SCENARIO_JOURNEY_TRANSFER_REPEAT_SLOTS # noqa: F841
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
_open_dispenser_period_a(page, live_server_url)
drag_transfer_to_period(
page,
E2E_DISP_RECIPE_1,
E2E_PERIOD_B,
live_server_url=live_server_url,
source_period_id=E2E_PERIOD_A,
expected_source_order=[E2E_DISP_RECIPE_2],
expected_target_order=[E2E_DISP_RECIPE_1],
)
assert page.locator("button.recipe-transfer-slot").count() == 0
select_period(
page,
E2E_PERIOD_B,
wait_recipe_id=E2E_DISP_RECIPE_1,
live_server_url=live_server_url,
expected_order=[E2E_DISP_RECIPE_1],
)
drag_transfer_to_period(
page,
E2E_DISP_RECIPE_1,
E2E_PERIOD_A,
slot_index=1,
live_server_url=live_server_url,
source_period_id=E2E_PERIOD_B,
expected_source_order=[],
expected_target_order=[E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1],
)
def test_journey_escape_then_reorder_still_works(authenticated_page, live_server_url) -> None:
SCENARIO_JOURNEY_ESCAPE_REORDER_MIX # noqa: F841
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
_open_dispenser_period_a(page, live_server_url)
abort_transfer_via_escape(page, E2E_DISP_RECIPE_1)
assert_period_order(
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
)
drag_reorder_recipe(
page,
E2E_DISP_RECIPE_1,
direction="down",
live_server_url=live_server_url,
period_id=E2E_PERIOD_A,
expected_order=[E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1],
)
assert list_dom_recipe_ids(page) == [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
def test_journey_mill_create_edit_delete_global(authenticated_page, live_server_url) -> None:
SCENARIO_JOURNEY_MILL_CREATE_DELETE # noqa: F841
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
page.locator("#createRecipeBtn").click()
wait_editor_open(page)
page.locator("#recipeName").fill("E2E Journey Mill")
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()
recipe_id = resp_info.value.json().get("id")
assert recipe_id
wait_editor_closed(page)
assert fetch_recipe(page, live_server_url, recipe_id)["name"] == "E2E Journey Mill"
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
page.locator(f'[data-action="select-recipe"][data-recipe-id="{recipe_id}"]').click()
wait_editor_open(page)
before_ings = len(fetch_recipe(page, live_server_url, recipe_id).get("ingredients") or [])
page.locator('[data-action="add-ingredient"]').click()
page.wait_for_function(
f"() => document.querySelectorAll('#ingredientsTableBody .zt-data-grid__row').length > {before_ings}"
)
page.locator('[data-action="remove-ingredient"]').first.click()
with page.expect_response(
lambda r: r.request.method == "PUT" and recipe_id in r.url,
timeout=15000,
):
page.locator('[data-action="save-and-exit"]').click()
wait_editor_closed(page)
assert len(fetch_recipe(page, live_server_url, recipe_id).get("ingredients") or []) == before_ings
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
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()
assert page.request.get(f"{live_server_url}/api/recipes/{recipe_id}").status == 404
assert page.request.get(f"{live_server_url}/api/recipes/{E2E_RECIPE_ID}").ok
def test_journey_settings_then_logout(authenticated_page, live_server_url) -> None:
SCENARIO_JOURNEY_SETTINGS_LOGOUT # noqa: F841
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
page.locator('[data-action="open-settings"]').first.click(force=True)
page.wait_for_selector('[data-action="toggle-sync-form"]', timeout=5000)
page.locator('[data-action="toggle-sync-form"]').click()
page.locator('[data-action="toggle-credentials-form"]').click()
page.locator('#settingsModal [data-action="close-settings"]').first.click()
page.wait_for_selector('[data-action="open-settings"]', state="visible", timeout=5000)
with page.expect_response(
lambda r: r.url.endswith("/api/auth/logout") and r.request.method == "POST",
timeout=10000,
):
page.locator('[data-action="logout"]').first.click()
page.wait_for_url(f"{live_server_url}/", timeout=10000)
check = page.request.get(f"{live_server_url}/api/auth/check")
assert check.ok
assert check.json().get("authenticated") is False
def test_journey_empty_period_create_then_delete_all(authenticated_page, live_server_url) -> None:
SCENARIO_JOURNEY_EMPTY_THEN_FULL # noqa: F841
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_DISP_ID}"]').click()
select_period(
page,
E2E_PERIOD_B,
live_server_url=live_server_url,
expected_order=[],
)
created_ids: list[str] = []
for i in range(2):
page.wait_for_selector(
f'[data-action="create-recipe"][data-period-id="{E2E_PERIOD_B}"]',
state="visible",
timeout=10000,
)
rid = create_recipe_in_period(
page,
E2E_PERIOD_B,
f"E2E Пустой B {i}",
live_server_url=live_server_url,
)
created_ids.append(rid)
wait_period_order(page, live_server_url, E2E_PERIOD_B, created_ids.copy())
assert len(period_recipe_ids(page, live_server_url, E2E_PERIOD_B)) == 2
for rid in created_ids:
select_period(page, E2E_PERIOD_B, wait_recipe_id=rid)
remaining = [x for x in created_ids if x != rid]
delete_recipe_unlink(
page,
rid,
live_server_url=live_server_url,
period_id=E2E_PERIOD_B,
expected_period_order=remaining,
)
assert_period_order(page, live_server_url, E2E_PERIOD_B, [])
def test_journey_mobile_flip_back_and_button_reorder(
authenticated_page, live_server_url
) -> None:
SCENARIO_JOURNEY_MOBILE_FLIP # noqa: F841
page = authenticated_page
page.set_viewport_size(MOBILE_VIEWPORT)
open_dispenser_period(
page,
E2E_PERIOD_A,
wait_recipe_id=E2E_DISP_RECIPE_1,
live_server_url=live_server_url,
expected_order=[E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2],
)
page.locator(f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]').click()
wait_editor_open(page)
page.locator('[data-action="show-recipe-edit-off"]').first.click()
wait_editor_closed(page)
page.locator('[data-action="mobile-dashboard-back"]').click()
wait_mobile_dashboard_step(page, "periods")
select_period(
page,
E2E_PERIOD_A,
wait_recipe_id=E2E_DISP_RECIPE_1,
live_server_url=live_server_url,
expected_order=[E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2],
)
click_move_recipe(page, E2E_DISP_RECIPE_1, "down")
wait_period_order(
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
)
page.locator('[data-action="mobile-dashboard-back"]').click()
wait_mobile_dashboard_step(page, "periods")
select_period(
page,
E2E_PERIOD_B,
live_server_url=live_server_url,
expected_order=[],
)
page.locator('[data-action="mobile-dashboard-back"]').click()
wait_mobile_dashboard_step(page, "periods")
select_period(
page,
E2E_PERIOD_A,
wait_recipe_id=E2E_DISP_RECIPE_1,
live_server_url=live_server_url,
expected_order=[E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1],
)
click_move_recipe(page, E2E_DISP_RECIPE_1, "up")
wait_period_order(
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
)
def test_journey_editor_cancel_then_save_other_recipe(
authenticated_page, live_server_url
) -> None:
SCENARIO_JOURNEY_EDITOR_CANCEL_REOPEN # noqa: F841
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
_open_dispenser_period_a(page, live_server_url)
before_name = fetch_recipe(page, live_server_url, E2E_DISP_RECIPE_1)["name"]
before_ings = recipe_ingredient_snapshot(page, live_server_url, E2E_DISP_RECIPE_1)
assert len(before_ings) == 1
assert before_ings[0][0] == E2E_DISP_ING_1
page.locator(f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]').click()
wait_editor_open(page)
page.locator("#recipeName").fill("E2E Не должно сохраниться")
page.locator('[data-action="add-ingredient"]').click()
page.wait_for_function(
"() => document.querySelectorAll('#ingredientsTableBody .ingredient-row').length > 1"
)
page.locator("#ingredientsTableBody .weight-per-head-input").first.fill("99.9")
page.locator('[data-action="show-recipe-edit-off"]').first.click()
wait_editor_closed(page)
assert fetch_recipe(page, live_server_url, E2E_DISP_RECIPE_1)["name"] == before_name
assert recipe_ingredient_snapshot(page, live_server_url, E2E_DISP_RECIPE_1) == before_ings
page.locator(f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_2}"]').click()
wait_editor_open(page)
page.locator("#recipeName").fill("E2E Второй переименован")
with page.expect_response(
lambda r: r.request.method == "PUT" and E2E_DISP_RECIPE_2 in r.url,
timeout=15000,
):
page.locator('[data-action="save-and-exit"]').click()
wait_editor_closed(page)
assert fetch_recipe(page, live_server_url, E2E_DISP_RECIPE_1)["name"] == before_name
assert recipe_ingredient_snapshot(page, live_server_url, E2E_DISP_RECIPE_1) == before_ings
assert fetch_recipe(page, live_server_url, E2E_DISP_RECIPE_2)["name"] == "E2E Второй переименован"
def test_journey_move_buttons_yoyo(authenticated_page, live_server_url) -> None:
SCENARIO_JOURNEY_MOVE_BUTTONS_YOYO # noqa: F841
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
_open_dispenser_period_a(page, live_server_url)
click_move_recipe(page, E2E_DISP_RECIPE_1, "down")
wait_period_order(
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
)
click_move_recipe(page, E2E_DISP_RECIPE_1, "up")
wait_period_order(
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
)
click_move_recipe(page, E2E_DISP_RECIPE_1, "down")
wait_period_order(
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
)
def test_journey_double_copy_paste_same_buffer(authenticated_page, live_server_url) -> None:
SCENARIO_JOURNEY_DOUBLE_COPY_PASTE # noqa: F841
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
_open_dispenser_period_a(page, live_server_url)
click_copy_recipe(page, E2E_DISP_RECIPE_1)
select_period(page, E2E_PERIOD_B, live_server_url=live_server_url, expected_order=[])
pasted_ids: list[str] = []
for n in range(2):
page.locator(f'[data-action="paste-recipe"][data-period-id="{E2E_PERIOD_B}"]').click()
wait_editor_open(page)
page.locator("#recipeName").fill(f"E2E Двойная копия {n}")
with page.expect_response(
lambda r: r.request.method == "POST"
and f"/api/periods/{E2E_PERIOD_B}/recipes" in r.url,
timeout=20000,
) as resp:
page.locator('[data-action="save-and-exit"]').click()
rid = resp.value.json().get("id")
assert rid
pasted_ids.append(rid)
wait_editor_closed(page)
wait_period_order(page, live_server_url, E2E_PERIOD_B, pasted_ids.copy())
assert_period_order(page, live_server_url, E2E_PERIOD_B, pasted_ids)
for rid in pasted_ids:
select_period(page, E2E_PERIOD_B, wait_recipe_id=rid)
remaining = [x for x in pasted_ids if x != rid]
delete_recipe_unlink(
page,
rid,
live_server_url=live_server_url,
period_id=E2E_PERIOD_B,
expected_period_order=remaining,
)
assert_period_order(page, live_server_url, E2E_PERIOD_B, [])
def test_journey_mill_dispenser_pingpong(authenticated_page, live_server_url) -> None:
SCENARIO_JOURNEY_MILL_DISPENSER_PINGPONG # noqa: F841
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
mill_before = fetch_recipe(page, live_server_url, E2E_RECIPE_ID)["name"]
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
page.locator(f'[data-action="select-recipe"][data-recipe-id="{E2E_RECIPE_ID}"]').click()
wait_editor_open(page)
page.locator("#recipeName").fill("E2E Mill черновик")
page.locator('[data-action="show-recipe-edit-off"]').first.click()
wait_editor_closed(page)
assert fetch_recipe(page, live_server_url, E2E_RECIPE_ID)["name"] == mill_before
_open_dispenser_period_a(page, live_server_url)
disp_before = recipe_ingredient_snapshot(page, live_server_url, E2E_DISP_RECIPE_1)
page.locator(f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]').click()
wait_editor_open(page)
page.locator("#toggleDryMatterMode").click()
page.locator('[data-action="show-recipe-edit-off"]').first.click()
wait_editor_closed(page)
assert recipe_ingredient_snapshot(page, live_server_url, E2E_DISP_RECIPE_1) == disp_before
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
page.locator(f'[data-action="select-recipe"][data-recipe-id="{E2E_RECIPE_ID}"]').click()
wait_editor_open(page)
page.locator("#recipeName").fill("E2E Mill сохранён")
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()
wait_editor_closed(page)
assert fetch_recipe(page, live_server_url, E2E_RECIPE_ID)["name"] == "E2E Mill сохранён"
_open_dispenser_period_a(page, live_server_url)
drag_transfer_to_period(
page,
E2E_DISP_RECIPE_1,
E2E_PERIOD_B,
live_server_url=live_server_url,
source_period_id=E2E_PERIOD_A,
expected_source_order=[E2E_DISP_RECIPE_2],
expected_target_order=[E2E_DISP_RECIPE_1],
)
def test_journey_distracted_help_settings_then_transfer(
authenticated_page, live_server_url
) -> None:
SCENARIO_JOURNEY_DISTRACTED_UI # noqa: F841
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
_open_dispenser_period_a(page, live_server_url)
page.locator(f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]').click()
wait_editor_open(page)
page.set_viewport_size(MOBILE_VIEWPORT)
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_function("() => !document.body.classList.contains('recipe-help-modal-open')")
page.set_viewport_size({"width": 1280, "height": 800})
page.locator('[data-action="open-settings"]').first.click(force=True)
page.locator('[data-action="toggle-sync-form"]').click()
page.locator('[data-action="toggle-credentials-form"]').click()
page.locator('#settingsModal [data-action="close-settings"]').first.click()
page.wait_for_selector("#settingsModal", state="hidden", timeout=5000)
page.locator('[data-action="show-recipe-edit-off"]').first.click()
wait_editor_closed(page)
restore_two_recipe_order_if_reversed(
page, live_server_url, E2E_PERIOD_A, E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2
)
assert_period_order(
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
)
abort_transfer_via_escape(page, E2E_DISP_RECIPE_2)
restore_two_recipe_order_if_reversed(
page, live_server_url, E2E_PERIOD_A, E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2
)
assert_period_order(
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
)
drag_transfer_to_period(
page,
E2E_DISP_RECIPE_2,
E2E_PERIOD_B,
live_server_url=live_server_url,
source_period_id=E2E_PERIOD_A,
expected_source_order=[E2E_DISP_RECIPE_1],
expected_target_order=[E2E_DISP_RECIPE_2],
)
def test_journey_mega_mix_all_operations(authenticated_page, live_server_url) -> None:
"""Мега-сценарий: всё подряд, как реальный зоотехник в понедельник утром."""
SCENARIO_JOURNEY_MEGA_MIX # noqa: F841
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
page.locator('[data-action="open-settings"]').first.click(force=True)
page.locator('#settingsModal [data-action="close-settings"]').first.click()
page.wait_for_selector("#settingsModal", state="hidden", timeout=5000)
_open_dispenser_period_a(page, live_server_url)
select_period(page, E2E_PERIOD_B, live_server_url=live_server_url, expected_order=[])
select_period(
page,
E2E_PERIOD_A,
wait_recipe_id=E2E_DISP_RECIPE_1,
live_server_url=live_server_url,
expected_order=[E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2],
)
click_move_recipe(page, E2E_DISP_RECIPE_1, "down")
wait_period_order(
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
)
abort_transfer_via_escape(page, E2E_DISP_RECIPE_1)
ensure_two_recipe_order(
page,
live_server_url,
E2E_PERIOD_A,
[E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1],
)
assert_period_order(
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_2, E2E_DISP_RECIPE_1]
)
ensure_two_recipe_order(
page,
live_server_url,
E2E_PERIOD_A,
[E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2],
)
assert_period_order(
page, live_server_url, E2E_PERIOD_A, [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
)
click_copy_recipe(page, E2E_DISP_RECIPE_2)
drag_transfer_to_period(
page,
E2E_DISP_RECIPE_1,
E2E_PERIOD_B,
live_server_url=live_server_url,
source_period_id=E2E_PERIOD_A,
expected_source_order=[E2E_DISP_RECIPE_2],
expected_target_order=[E2E_DISP_RECIPE_1],
)
select_period(
page,
E2E_PERIOD_B,
wait_recipe_id=E2E_DISP_RECIPE_1,
live_server_url=live_server_url,
expected_order=[E2E_DISP_RECIPE_1],
)
page.locator(f'[data-action="paste-recipe"][data-period-id="{E2E_PERIOD_B}"]').click()
wait_editor_open(page)
with page.expect_response(
lambda r: r.request.method == "POST"
and f"/api/periods/{E2E_PERIOD_B}/recipes" in r.url,
timeout=20000,
) as paste_resp:
page.locator('[data-action="save-and-exit"]').click()
pasted_id = paste_resp.value.json().get("id")
assert pasted_id
wait_editor_closed(page)
wait_period_order(
page, live_server_url, E2E_PERIOD_B, [E2E_DISP_RECIPE_1, pasted_id]
)
open_dispenser_period(
page,
E2E_PERIOD_B,
wait_recipe_id=E2E_DISP_RECIPE_1,
live_server_url=live_server_url,
expected_order=[E2E_DISP_RECIPE_1, pasted_id],
)
click_move_recipe(page, pasted_id, "up")
wait_period_order(
page, live_server_url, E2E_PERIOD_B, [pasted_id, E2E_DISP_RECIPE_1]
)
page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click()
page.wait_for_selector("#createRecipeBtn", timeout=10000)
assert fetch_recipe(page, live_server_url, E2E_RECIPE_ID).get("name")
open_dispenser_period(
page,
E2E_PERIOD_B,
wait_recipe_id=pasted_id,
live_server_url=live_server_url,
expected_order=[pasted_id, E2E_DISP_RECIPE_1],
)
delete_recipe_unlink(
page,
pasted_id,
live_server_url=live_server_url,
period_id=E2E_PERIOD_B,
expected_period_order=[E2E_DISP_RECIPE_1],
)
assert page.request.get(f"{live_server_url}/api/recipes/{pasted_id}").ok
+14
View File
@@ -0,0 +1,14 @@
"""E2E smoke: /reports."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.e2e
def test_reports_page_loads(zootech_authenticated_page, live_server_url) -> None:
page = zootech_authenticated_page
page.goto(f"{live_server_url}/reports")
page.wait_for_load_state("domcontentloaded")
assert page.locator("body").is_visible()
+13
View File
@@ -0,0 +1,13 @@
"""E2E smoke: /unloading (localhost kiosk bypass)."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.e2e
def test_unloading_page_loads(page, live_server_url) -> None:
page.goto(f"{live_server_url}/unloading")
page.wait_for_load_state("domcontentloaded")
assert page.locator("body").is_visible()
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<Standard_XML_Data>
<Lab_Name Value="АгроСтар" Name="Lab_Name"/>
<Sample_Data>
<Sample_No Value="471505270426" Name="Sample_No"/>
<Name Value="АО ПЗ Мельниково" Name="Name"/>
<Farm_ID Value="4715" Name="Farm_ID"/>
<Lot_name Value="" Name="Lot_name"/>
<Date_Printed Value="2026-04-27" Name="Date_Printed"/>
<Type Value="Mixed haylage" Name="Type"/>
<Desc_1 Value="Силос разнотравье (многолетние травы) яма 5 закрытая" Name="Desc_1"/>
<Desc_2 Value="" Name="Desc_2"/>
<Desc_3 Value="" Name="Desc_3"/>
<Product_code Value="1C:CN" Name="Product_code"/>
<DM Value="25.06" Name="DM"/>
<CP Value="10.75" Name="CP"/>
<NDF Value="64.55" Name="NDF"/>
<ADF Value="44.44" Name="ADF"/>
<Fat_EE Value="4.45" Name="Fat_EE"/>
<Ash Value="9.48" Name="Ash"/>
<Ca Value="0.73" Name="Ca"/>
<P Value="0.26" Name="P"/>
<Mg Value="0.14" Name="Mg"/>
<K Value="1.45" Name="K"/>
<S Value="0.17" Name="S"/>
<Cl Value="0.39" Name="Cl"/>
<Sugar_WSC Value="1.84" Name="Sugar_WSC"/>
<Starch Value="0.52" Name="Starch"/>
<NFC Value="17.66" Name="NFC"/>
<TDN Value="53.01" Name="TDN"/>
<NDICP_CP Value="27.77" Name="NDICP_CP"/>
<NDFDom_IV_30hr Value="51.91" Name="NDFDom_IV_30hr"/>
<Lys Value="0.44" Name="Lys"/>
<Met Value="0.29" Name="Met"/>
</Sample_Data>
</Standard_XML_Data>
+9
View File
@@ -0,0 +1,9 @@
[
{
"id": "prof-beef-001",
"key": "beef_grow_400",
"label": "Откорм 400 кг",
"type": "BEEF",
"norms_data": {"dry_matter": {"min": 4000, "max": 5000}}
}
]
+8
View File
@@ -0,0 +1,8 @@
[
{
"id": "tab-ing-1",
"external_no": 1001,
"name": "Ячмень",
"nutrients": {"Сыр. Протеин": 115, "СВ": 860}
}
]
+101
View File
@@ -0,0 +1,101 @@
[
{
"id": "concentrate_with_omd",
"context": {"feed_group": "concentrate", "is_main_feed": false},
"input": {
"СВ": 906,
"Сыр. Протеин": 440,
"Сырая клетч": 108,
"Сырой жир": 70,
"Сырая зола": 64,
"ВРХ Орг Вещ": 79,
"КРС Протеин": 88,
"КРС Сырой жир": 94,
"КРС Сырая клетч": 82,
"КРС БЭВ": 86
},
"expected": {
"Перевар Орг Вещ": 665.18,
"OЭ КРС форм": 11.796,
"ЧЭЛ - КРС Форм": 7.186,
"уСП формул": 174.0553,
"БРА": 42.5512
}
},
{
"id": "hay_with_omd",
"context": {"feed_group": "rough", "is_main_feed": true},
"input": {
"СВ": 830,
"Осн.Корм": 1000,
"Сыр. Протеин": 117,
"Сырая клетч": 266,
"Сырой жир": 23,
"Сырая зола": 72,
"ВРХ Орг Вещ": 64,
"КРС Протеин": 57,
"КРС Сырой жир": 39,
"КРС Сырая клетч": 66,
"КРС БЭВ": 63
},
"expected": {
"Перевар Орг Вещ": 485.12,
"OЭ КРС форм": 7.3599,
"ЧЭЛ - КРС Форм": 4.2634
}
},
{
"id": "straw_default_omd_65",
"context": {"feed_group": "rough", "is_main_feed": true},
"input": {
"СВ": 860,
"Осн.Корм": 1000,
"Сыр. Протеин": 31,
"Сырая клетч": 384,
"Сырой жир": 13,
"Сырая зола": 58,
"КРС Протеин": 30,
"КРС Сырой жир": 34,
"КРС Сырая клетч": 58,
"КРС БЭВ": 44
},
"expected": {
"Перевар Орг Вещ": 521.3,
"OЭ КРС форм": 7.5636,
"ЧЭЛ - КРС Форм": 4.388,
"БРА": -10.0182
}
},
{
"id": "succulent_default_omd_72",
"context": {"feed_group": "succulent", "is_main_feed": true},
"input": {
"СВ": 110,
"Осн.Корм": 1000,
"Сыр. Протеин": 12,
"Сырая клетч": 10,
"Сырой жир": 1,
"Сырая зола": 8
},
"expected": {
"Перевар Орг Вещ": 66.3
}
},
{
"id": "rnb_formula",
"context": {"feed_group": "concentrate", "is_main_feed": false},
"input": {
"СВ": 906,
"Сыр. Протеин": 440,
"Сырая клетч": 108,
"Сырой жир": 70,
"Сырая зола": 64,
"ВРХ Орг Вещ": 79,
"КРС Протеин": 88,
"КРС Сырой жир": 94,
"КРС Сырая клетч": 82,
"КРС БЭВ": 86
},
"expected_rnb": 42.5512
}
]
+5
View File
@@ -0,0 +1,5 @@
# Minimal blocklist for pytest (login/password validation).
password
12345678
qwerty
admin123
+1
View File
@@ -0,0 +1 @@
"""Общие хелперы для тестов WESP."""
@@ -0,0 +1,133 @@
"""Хелперы: мастер-рацион vs план на день vs экран оператора загрузки."""
from __future__ import annotations
from datetime import date
from typing import Any
from flask.testing import FlaskClient
DAILY_PLAN_SYNC_TABLES: tuple[str, ...] = (
"daily_trip_skip",
"daily_ingredient_skip",
"daily_unloading_group_skip",
"daily_ingredient_replacement",
"daily_component_norm_adjustment",
)
KIOSK_HEADERS = {"X-Wesp-Kiosk": "1"}
def plan_date_today() -> str:
return date.today().isoformat()
def master_recipe_json(client: FlaskClient, recipe_id: str) -> dict[str, Any]:
resp = client.get(f"/api/recipes/{recipe_id}")
assert resp.status_code == 200, resp.get_data(as_text=True)
return resp.get_json()
def plan_recipe_json(
client: FlaskClient,
recipe_id: str,
*,
plan_date: str | None = None,
kiosk: bool = True,
) -> dict[str, Any]:
iso = (plan_date or plan_date_today())[:10]
headers = KIOSK_HEADERS if kiosk else {}
resp = client.get(
f"/api/recipes/{recipe_id}?date={iso}",
headers=headers,
)
assert resp.status_code == 200, resp.get_data(as_text=True)
return resp.get_json()
def first_ingredient_amount(payload: dict[str, Any]) -> float:
ingredients = payload.get("ingredients") or []
assert ingredients, "expected at least one ingredient"
return float(ingredients[0].get("amount") or 0)
def ingredient_amounts(payload: dict[str, Any]) -> list[float]:
return [float(i.get("amount") or 0) for i in (payload.get("ingredients") or [])]
def operator_weight_display(client: FlaskClient, recipe_id: str) -> dict[str, Any]:
set_resp = client.post("/api/set_current_recipe", json={"recipe_id": recipe_id})
assert set_resp.status_code == 200, set_resp.get_data(as_text=True)
display = client.get("/api/weight_display_data")
assert display.status_code == 200, display.get_data(as_text=True)
return display.get_json()
def assert_operator_matches_plan_not_master(
client: FlaskClient,
recipe_id: str,
*,
plan_date: str | None = None,
expect_plan_differs_from_master: bool = True,
) -> dict[str, Any]:
"""Оператор (weight_display) = план; мастер — базовый рецепт без date=."""
master = master_recipe_json(client, recipe_id)
plan = plan_recipe_json(client, recipe_id, plan_date=plan_date)
operator = operator_weight_display(client, recipe_id)
assert operator.get("status") == "active", operator
plan_amounts = ingredient_amounts(plan)
master_amounts = ingredient_amounts(master)
assert plan_amounts, "plan must expose ingredients"
if expect_plan_differs_from_master:
assert plan_amounts != master_amounts or plan.get("ingredients") != master.get(
"ingredients"
), "plan should differ from master for this scenario"
assert operator["total_component"] == int(round(plan_amounts[0]))
assert operator["total_mixture"] == int(round(sum(plan_amounts)))
assert operator["total_component"] != int(round(master_amounts[0])) or len(
plan_amounts
) != len(master_amounts)
return {
"master": master,
"plan": plan,
"operator": operator,
"master_amounts": master_amounts,
"plan_amounts": plan_amounts,
}
def assert_daily_plan_tables_registered() -> None:
from app.models import (
DailyComponentNormAdjustment,
DailyIngredientReplacement,
DailyIngredientSkip,
DailyTripSkip,
DailyUnloadingGroupSkip,
_sync_models,
)
from app.services.sync_manager import SERVER_MASTER_TABLES, SNAPSHOT_MODELS
from app.services.sync_record_data import get_record_data_for_sync
model_by_table = {
DailyTripSkip.__tablename__: DailyTripSkip,
DailyIngredientSkip.__tablename__: DailyIngredientSkip,
DailyUnloadingGroupSkip.__tablename__: DailyUnloadingGroupSkip,
DailyIngredientReplacement.__tablename__: DailyIngredientReplacement,
DailyComponentNormAdjustment.__tablename__: DailyComponentNormAdjustment,
}
assert set(DAILY_PLAN_SYNC_TABLES) == set(model_by_table)
sync_model_tables = {m.__tablename__ for m in _sync_models if m.__tablename__.startswith("daily_")}
assert sync_model_tables == set(DAILY_PLAN_SYNC_TABLES)
snapshot_tables = {m.__tablename__ for m in SNAPSHOT_MODELS}
for table in DAILY_PLAN_SYNC_TABLES:
assert table in snapshot_tables, f"{table} missing from SNAPSHOT_MODELS"
assert table in SERVER_MASTER_TABLES, f"{table} missing from SERVER_MASTER_TABLES"
# smoke: get_record_data_for_sync resolves model (None без строки — ок)
assert get_record_data_for_sync("daily_trip_skip", "nonexistent-id") is None
@@ -0,0 +1,161 @@
"""Фабрики данных для тестов кормораздатчика (period_recipes)."""
from __future__ import annotations
from datetime import datetime
from typing import List, Tuple
from sqlalchemy import select
from app import db
from app.models import (
Component,
FeedDispenser,
FeedingPeriod,
Ingredient,
PeriodRecipe,
Recipe,
UnloadingGroup,
)
E2E_DISP_ID = "e2e-disp-1"
E2E_PERIOD_A = "e2e-period-a"
E2E_PERIOD_B = "e2e-period-b"
E2E_DISP_RECIPE_1 = "e2e-disp-recipe-1"
E2E_DISP_RECIPE_2 = "e2e-disp-recipe-2"
E2E_DISP_COMP = "e2e-disp-comp"
def seed_dispenser_period_recipes() -> Tuple[str, str, List[str]]:
"""Кормораздатчик + 2 периода + 2 рецепта в period A. Возвращает (disp_id, period_a_id, recipe_ids)."""
now = datetime.now()
db.session.add_all(
[
FeedDispenser(
id=E2E_DISP_ID,
name="E2E Раздатчик",
farm="Ферма",
operator="Тест",
type="dispenser",
content_hash="",
),
FeedingPeriod(id=E2E_PERIOD_A, name="E2E Утро", dispenser_id=E2E_DISP_ID),
FeedingPeriod(id=E2E_PERIOD_B, name="E2E Вечер", dispenser_id=E2E_DISP_ID),
Component(
id=E2E_DISP_COMP,
name="E2E Disp компонент",
type="grain",
dry_matter=55.0,
protein=0.0,
energy=0.0,
price=0.0,
),
Recipe(
id=E2E_DISP_RECIPE_1,
name="E2E Рейс 1",
heads_per_trip=20,
mixing_time=3,
trip_percent=100.0,
target_component_id=E2E_DISP_COMP,
content_hash="",
),
Recipe(
id=E2E_DISP_RECIPE_2,
name="E2E Рейс 2",
heads_per_trip=15,
mixing_time=4,
trip_percent=100.0,
target_component_id=E2E_DISP_COMP,
content_hash="",
),
]
)
db.session.flush()
for idx, (rid, ing_id) in enumerate(
((E2E_DISP_RECIPE_1, "e2e-disp-ing-1"), (E2E_DISP_RECIPE_2, "e2e-disp-ing-2")),
1,
):
db.session.add(
Ingredient(
id=ing_id,
name=f"Ing {idx}",
weight_per_head=float(idx),
amount=float(idx * 10),
dry_matter=55.0,
component_id=E2E_DISP_COMP,
order=1,
recipe_id=rid,
created_by="system",
updated_by="system",
)
)
db.session.add(
UnloadingGroup(
id=f"e2e-disp-grp-{idx}",
name=f"Г{idx}",
distribution_type="percent",
value=100.0,
weight=10.0,
order=1,
recipe_id=rid,
created_by="system",
updated_by="system",
)
)
for ord_, rid in enumerate((E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2), 0):
db.session.add(
PeriodRecipe(
period_id=E2E_PERIOD_A,
recipe_id=rid,
order=ord_,
created_at=now,
created_by="system",
updated_by="system",
)
)
db.session.commit()
return E2E_DISP_ID, E2E_PERIOD_A, [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
def reset_e2e_dispenser_period_state() -> None:
"""Восстановить рейсы 1–2 в period A после мутирующих E2E (перенос, удаление, порядок)."""
now = datetime.now()
period_b_rows = db.session.execute(
select(PeriodRecipe).where(
PeriodRecipe.period_id == E2E_PERIOD_B,
PeriodRecipe.is_deleted.is_(False),
)
).scalars().all()
for row in period_b_rows:
db.session.delete(row)
db.session.flush()
for rid in (E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2):
row = db.session.get(PeriodRecipe, {"period_id": E2E_PERIOD_A, "recipe_id": rid})
if row is not None:
db.session.delete(row)
db.session.flush()
for ord_, rid in enumerate((E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2)):
db.session.add(
PeriodRecipe(
period_id=E2E_PERIOD_A,
recipe_id=rid,
order=ord_,
created_at=now,
updated_at=now,
created_by="system",
updated_by="system",
)
)
db.session.commit()
def get_period_recipe_order(period_id: str) -> List[str]:
rows = db.session.execute(
select(PeriodRecipe.recipe_id)
.where(
PeriodRecipe.period_id == period_id,
PeriodRecipe.is_deleted.is_(False),
)
.order_by(PeriodRecipe.order.asc())
).scalars().all()
return [str(x) for x in rows]
@@ -0,0 +1,42 @@
"""Helpers for lab module tests."""
from __future__ import annotations
import json
from pathlib import Path
from app import db
from app.lab.services.component_nutrients import upsert_from_api_dict
from app.models import Component
from app.models.base import default_uuid
FIXTURES_DIR = Path(__file__).resolve().parent.parent / "fixtures" / "lab"
def load_fixture(name: str) -> list[dict]:
path = FIXTURES_DIR / name
return json.loads(path.read_text(encoding="utf-8"))
def make_component(
*,
name: str = "Ячмень",
external_no: int | None = 1001,
dry_matter: float = 86.0,
nutrients: dict | None = None,
) -> Component:
comp = Component(
id=default_uuid(),
name=name,
type="Зерновые",
dry_matter=dry_matter,
protein=10.0,
energy=12.0,
price=15.0,
external_no=external_no,
)
db.session.add(comp)
db.session.flush()
upsert_from_api_dict(comp.id, nutrients or {"Сыр. Протеин": 115})
db.session.flush()
return comp
@@ -0,0 +1,174 @@
"""Фабрики данных для тестов кормоцеха и рецептов."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import func, select
from app import db
from app.models import Component, Ingredient, Recipe, SyncQueue, UnloadingGroup
def create_component(*, name: str = "Компонент А", dry_matter: float = 50.0) -> str:
comp_id = str(uuid.uuid4())
db.session.add(
Component(
id=comp_id,
name=name,
type="grain",
dry_matter=dry_matter,
protein=0.0,
energy=0.0,
price=0.0,
)
)
db.session.flush()
return comp_id
def create_recipe_with_children(
*,
name: str = "Тестовый рецепт",
heads: int = 10,
component_ids: Optional[List[str]] = None,
with_groups: bool = True,
) -> Tuple[str, List[str], List[str]]:
"""Рецепт + ингредиенты (+ опционально группы). Возвращает recipe_id, ingredient_ids, group_ids."""
if component_ids is None:
component_ids = [create_component(name=f"К{i + 1}") for i in range(2)]
recipe_id = str(uuid.uuid4())
recipe = Recipe(
id=recipe_id,
name=name,
heads_per_trip=heads,
mixing_time=5,
trip_percent=100.0,
target_component_id=component_ids[0],
content_hash="",
)
db.session.add(recipe)
db.session.flush()
ingredient_ids: List[str] = []
for idx, comp_id in enumerate(component_ids, 1):
ing = Ingredient(
name=f"Ing {idx}",
weight_per_head=float(idx),
amount=float(idx * heads),
dry_matter=50.0 + idx,
component_id=comp_id,
order=idx,
recipe_id=recipe_id,
created_by="system",
updated_by="system",
)
db.session.add(ing)
db.session.flush()
ingredient_ids.append(str(ing.id))
group_ids: List[str] = []
if with_groups:
for idx in range(1, 3):
grp = UnloadingGroup(
name=f"Г{idx}",
distribution_type="percent",
value=50.0,
weight=float(idx * 10),
order=idx,
recipe_id=recipe_id,
created_by="system",
updated_by="system",
)
db.session.add(grp)
db.session.flush()
group_ids.append(str(grp.id))
db.session.commit()
return recipe_id, ingredient_ids, group_ids
def recipe_update_payload(
recipe: Recipe,
*,
ingredients: List[Dict[str, Any]],
groups: Optional[List[Dict[str, Any]]] = None,
deleted_ingredient_ids: Optional[List[str]] = None,
deleted_group_ids: Optional[List[str]] = None,
target_component_id: Optional[str] = None,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"name": recipe.name,
"heads_count": recipe.heads_per_trip,
"mixing_time": recipe.mixing_time,
"trip_percent": recipe.trip_percent,
"target_component_id": target_component_id or recipe.target_component_id,
"dry_matter_locked": False,
"unloading_link_broken": False,
"ingredients": ingredients,
"unloading_groups": groups or [],
}
if deleted_ingredient_ids:
payload["deleted_ingredient_ids"] = deleted_ingredient_ids
if deleted_group_ids:
payload["deleted_unloading_group_ids"] = deleted_group_ids
return payload
def sync_task_exists(
table_name: str,
record_id: str,
action: str,
) -> bool:
row = db.session.execute(
select(SyncQueue).where(
SyncQueue.table_name == table_name,
SyncQueue.record_id == record_id,
SyncQueue.action == action,
)
).scalar_one_or_none()
return row is not None
def count_sync_tasks(
table_name: str,
record_id: str,
action: str,
) -> int:
return int(
db.session.scalar(
select(func.count())
.select_from(SyncQueue)
.where(
SyncQueue.table_name == table_name,
SyncQueue.record_id == record_id,
SyncQueue.action == action,
)
)
or 0
)
def ingredient_payload_from_row(ing: Ingredient, *, order: Optional[int] = None) -> Dict[str, Any]:
return {
"id": str(ing.id),
"component_id": str(ing.component_id),
"weight_per_head": ing.weight_per_head,
"amount": ing.amount,
"dry_matter": ing.dry_matter,
"order": order if order is not None else ing.order,
}
def group_payload_from_row(grp: UnloadingGroup, *, order: Optional[int] = None) -> Dict[str, Any]:
return {
"id": str(grp.id),
"name": grp.name,
"distribution_type": grp.distribution_type,
"value": grp.value,
"weight": grp.weight,
"order": order if order is not None else grp.order,
}
@@ -0,0 +1,63 @@
"""Матрица сценариев /recipes — id для привязки тестов и guard в ui_contract."""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Literal
Layer = Literal["api", "dual", "e2e"]
@dataclass(frozen=True)
class RecipeScenario:
id: str
layer: Layer
description: str
SCENARIOS: List[RecipeScenario] = [
RecipeScenario("HAPPY_ONE_RECIPE_MOVE", "api", "move up/down одного рейса в периоде"),
RecipeScenario("EDGE_EMPTY_PERIOD_TRANSFER", "api", "transfer в пустой период index 0"),
RecipeScenario("EDGE_MAX_RECIPES_ORDER", "api", "много рейсов — move крайних позиций"),
RecipeScenario("TRANSFER_ENQUEUE_SOURCE_DELETE", "api", "sync delete исходного period_recipes"),
RecipeScenario("TRANSFER_REPEAT_A_B_A", "api", "повторный transfer A→B→A"),
RecipeScenario("DUAL_UNLINK_BOTH_TERMINALS", "dual", "unlink period → A и B"),
RecipeScenario("DUAL_TRANSFER_BOTH_TERMINALS", "dual", "transfer period → A и B"),
RecipeScenario("DUAL_TRANSFER_THEN_REORDER", "dual", "transfer + reorder на B"),
RecipeScenario("DUAL_OFFLINE_CATCHUP_TRANSFER", "dual", "B offline, transfer, догон"),
RecipeScenario("DUAL_LATE_JOIN_AFTER_TRANSFER", "dual", "late join после transfer"),
RecipeScenario("DUAL_REORDER_AND_NEW_GROUP", "dual", "reorder ингредиентов + новая группа → A и B"),
RecipeScenario("DUAL_ADD_GROUP_UPDATES_EXISTING", "dual", "60/40 группы на peer, не 140%"),
RecipeScenario("CALCULATE_FROM_DRY_MATTER", "api", "POST /api/recipes/calculate с calculateFromDryMatter"),
RecipeScenario("SAVE_DRY_MATTER_LOCKED", "api", "сохранение рецепта с dry_matter_locked пересчитывает веса"),
RecipeScenario("DUAL_PUSH_ROUNDTRIP", "dual", "terminal A push → server → B"),
RecipeScenario("DUAL_CONCURRENT_RECIPE", "dual", "устаревший push не перезаписывает master"),
RecipeScenario("DUAL_REPORTS_SYNC", "dual", "loading_report на обоих терминалах"),
RecipeScenario("DUAL_OFFLINE_CATCHUP_GROUPS", "dual", "offline B догоняет reorder + группу"),
RecipeScenario("E2E_DRAG_REORDER", "e2e", "DnD ручкой — порядок + API"),
RecipeScenario("E2E_MOVE_RECIPE_UP", "e2e", "кнопка move-recipe-up меняет order"),
RecipeScenario("E2E_REORDER_THEN_TRANSFER", "e2e", "reorder затем transfer"),
RecipeScenario("E2E_TRANSFER_THEN_REORDER", "e2e", "transfer затем reorder на B"),
RecipeScenario("E2E_TRANSFER_REPEAT", "e2e", "transfer A→B→A"),
RecipeScenario("E2E_TRANSFER_ESCAPE", "e2e", "Escape отменяет transfer"),
RecipeScenario("E2E_HEADS_RECALC", "e2e", "смена голов пересчитывает вес группы heads"),
RecipeScenario("DUAL_E2E_DND_SYNC", "e2e", "DnD на A → sync → B видит order"),
RecipeScenario("DUAL_E2E_TRANSFER_REORDER", "e2e", "transfer на A → sync → B видит рейс"),
RecipeScenario("E2E_EDITOR_UNLOADING_GROUP", "e2e", "add/remove unloading group + save"),
RecipeScenario("E2E_EDITOR_CANCEL", "e2e", "show-recipe-edit-off без save"),
RecipeScenario("JOURNEY_MORNING_ROUTINE", "e2e", "полный еблан: save→reorder→copy→paste→unlink"),
RecipeScenario("JOURNEY_TRANSFER_REPEAT_SLOTS", "e2e", "полный еблан: transfer со слотом A↔B"),
RecipeScenario("JOURNEY_ESCAPE_REORDER_MIX", "e2e", "полный еблан: escape transfer → reorder"),
RecipeScenario("JOURNEY_MILL_CREATE_DELETE", "e2e", "полный еблан: mill create/save/delete global"),
RecipeScenario("JOURNEY_SETTINGS_LOGOUT", "e2e", "полный еблан: settings → logout"),
RecipeScenario("JOURNEY_EMPTY_THEN_FULL", "e2e", "полный еблан: пустой period → create → delete all"),
RecipeScenario("JOURNEY_MOBILE_FLIP", "e2e", "полный еблан: mobile шаги + back + кнопки reorder"),
RecipeScenario("JOURNEY_EDITOR_CANCEL_REOPEN", "e2e", "полный еблан: cancel → другой рейс → save"),
RecipeScenario("JOURNEY_MOVE_BUTTONS_YOYO", "e2e", "полный еблан: up/down спам кнопками"),
RecipeScenario("JOURNEY_DOUBLE_COPY_PASTE", "e2e", "полный еблан: два paste одного copy в period B"),
RecipeScenario("JOURNEY_MILL_DISPENSER_PINGPONG", "e2e", "полный еблан: mill↔dispenser переключения"),
RecipeScenario("JOURNEY_DISTRACTED_UI", "e2e", "полный еблан: help/settings отвлечение → transfer"),
RecipeScenario("JOURNEY_MEGA_MIX", "e2e", "полный еблан: мега-смесь всех операций подряд"),
]
SCENARIO_IDS = [s.id for s in SCENARIOS]
@@ -0,0 +1,210 @@
"""Инвентарь UI страницы /recipes — источник правды для контракт- и E2E-тестов."""
from __future__ import annotations
from pathlib import Path
from typing import Dict, List, Tuple
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
STATIC = PROJECT_ROOT / "static"
# Ключевые DOM id (dashboard + editor + mobile)
DOM_IDS: List[str] = [
"wespZootechNavMount",
"recipesMobileNav",
"recipesMobileNavTitle",
"recipesDashboard",
"dispensersCol",
"periodsCol",
"recipesCol",
"dispensersList",
"periodsList",
"recipesList",
"recipesCardHeaderText",
"createRecipeBtn",
"recipeEdit",
"recipeEditSkeletonPanel",
"recipeForm",
"recipeName",
"headsCount",
"mixingTime",
"tripPercent",
"ingredientsTable",
"ingredientsTableBody",
"unloadingGroupsCard",
"unloadingGroupsTableBody",
"componentSelectionCard",
"targetComponentSelect",
"ingredientMobileSheet",
"unloadingGroupMobileSheet",
"recipeHelpModal",
]
# data-action → файл(ы), где должен быть handler
DATA_ACTION_HANDLERS: Dict[str, List[str]] = {
"mobile-dashboard-back": ["static/js/pages/recipes-page.js"],
"open-recipe-help": ["static/js/pages/recipes-page.js"],
"close-recipe-help": ["static/js/pages/recipes-page.js"],
"logout": ["static/js/pages/recipes-page.js", "static/js/wesp-zootech-nav.js"],
"add-ingredient": ["static/js/modules/recipes/recipe-editor.js"],
"toggle-unloading-link": ["static/js/modules/recipes/recipe-editor.js"],
"add-unloading-group": ["static/js/modules/recipes/recipe-editor.js"],
"show-recipe-edit-off": ["static/js/modules/recipes/recipe-editor.js"],
"print-all-recipes": ["static/js/modules/recipes/recipe-editor.js"],
"print-recipe": ["static/js/modules/recipes/recipe-editor.js"],
"save-and-exit": ["static/js/modules/recipes/recipe-editor.js"],
"move-group-up": ["static/js/modules/recipes/recipe-editor.js"],
"move-group-down": ["static/js/modules/recipes/recipe-editor.js"],
"remove-unloading-group": ["static/js/modules/recipes/recipe-editor.js"],
"move-ingredient-up": ["static/js/modules/recipes/recipe-editor.js"],
"move-ingredient-down": ["static/js/modules/recipes/recipe-editor.js"],
"remove-ingredient": ["static/js/modules/recipes/recipe-editor.js"],
"open-ingredient-sheet": ["static/js/modules/recipes/recipe-editor.js"],
"close-ingredient-sheet": ["static/js/modules/recipes/recipe-editor.js"],
"open-unloading-group-sheet": ["static/js/modules/recipes/recipe-editor.js"],
"close-unloading-group-sheet": ["static/js/modules/recipes/recipe-editor.js"],
"group-type-change": ["static/js/modules/recipes/recipe-editor.js"],
"group-value-change": ["static/js/modules/recipes/recipe-editor.js"],
"ingredient-change": ["static/js/modules/recipes/recipe-editor.js"],
"dry-matter-percent-change": ["static/js/modules/recipes/recipe-editor.js"],
"recalculate-weights": ["static/js/modules/recipes/recipe-editor.js"],
"recalculate-total-weight": ["static/js/modules/recipes/recipe-editor.js"],
"select-dispenser": ["static/js/modules/recipes/dispenser-selector.js"],
"select-recipe": ["static/js/modules/recipes/dispenser-selector.js"],
"select-period": ["static/js/modules/recipes/dispenser-selector.js"],
"copy-recipe": ["static/js/modules/recipes/dispenser-selector.js"],
"delete-recipe": ["static/js/modules/recipes/dispenser-selector.js"],
"unskip-recipe-today": ["static/js/modules/recipes/dispenser-selector.js"],
"unskip-ingredient-parts-today": ["static/js/modules/recipes/dispenser-selector.js"],
"unskip-group-parts-today": ["static/js/modules/recipes/dispenser-selector.js"],
"paste-recipe": ["static/js/modules/recipes/dispenser-selector.js"],
"create-recipe": ["static/js/modules/recipes/dispenser-selector.js"],
"move-recipe-up": ["static/js/modules/recipes/dispenser-selector.js"],
"move-recipe-down": ["static/js/modules/recipes/dispenser-selector.js"],
"recipe-drag-handle": ["static/js/modules/recipes/dispenser-selector.js"],
"open-settings": ["static/js/modules/recipes/sync-panel.js", "static/js/wesp-zootech-nav.js"],
"close-settings": ["static/js/modules/recipes/sync-panel.js"],
"toggle-credentials-form": ["static/js/modules/recipes/sync-panel.js"],
"toggle-sync-form": ["static/js/modules/recipes/sync-panel.js"],
"change-credentials": ["static/js/modules/recipes/sync-panel.js"],
"sync-edit-client": ["static/js/modules/recipes/sync-panel.js"],
"sync-delete-client": ["static/js/modules/recipes/sync-panel.js"],
"sync-save-client": ["static/js/modules/recipes/sync-panel.js"],
"sync-cancel-edit": ["static/js/modules/recipes/sync-panel.js"],
}
# data-action → уровень теста, который должен покрывать action (e2e | api | contract)
ACTION_COVERAGE_TARGET: Dict[str, str] = {
"mobile-dashboard-back": "e2e",
"open-recipe-help": "e2e",
"close-recipe-help": "e2e",
"logout": "e2e",
"add-ingredient": "e2e",
"toggle-unloading-link": "e2e",
"add-unloading-group": "contract",
"show-recipe-edit-off": "contract",
"print-all-recipes": "e2e",
"print-recipe": "e2e",
"save-and-exit": "e2e",
"move-group-up": "api",
"move-group-down": "api",
"remove-unloading-group": "contract",
"move-ingredient-up": "api",
"move-ingredient-down": "api",
"remove-ingredient": "e2e",
"open-ingredient-sheet": "e2e",
"close-ingredient-sheet": "e2e",
"open-unloading-group-sheet": "contract",
"close-unloading-group-sheet": "contract",
"group-type-change": "e2e",
"group-value-change": "contract",
"ingredient-change": "contract",
"dry-matter-percent-change": "e2e",
"recalculate-weights": "contract",
"recalculate-total-weight": "contract",
"select-dispenser": "e2e",
"select-recipe": "e2e",
"select-period": "e2e",
"copy-recipe": "e2e",
"delete-recipe": "e2e",
"unskip-recipe-today": "api",
"unskip-ingredient-parts-today": "api",
"unskip-group-parts-today": "api",
"paste-recipe": "e2e",
"create-recipe": "e2e",
"move-recipe-up": "e2e",
"move-recipe-down": "e2e",
"recipe-drag-handle": "e2e",
"open-settings": "e2e",
"close-settings": "e2e",
"toggle-credentials-form": "e2e",
"toggle-sync-form": "e2e",
"change-credentials": "contract",
"sync-edit-client": "contract",
"sync-delete-client": "contract",
"sync-save-client": "contract",
"sync-cancel-edit": "contract",
}
# fetch URL patterns в JS страницы рецептов
API_ENDPOINTS: List[Tuple[str, List[str]]] = [
("/api/feed_dispensers", ["static/js/pages/recipes-data-controller.js"]),
("/api/feed_dispensers/", ["static/js/pages/recipes-data-controller.js", "static/js/pages/recipes-operations-controller.js"]),
("/api/periods/", ["static/js/pages/recipes-data-controller.js", "static/js/pages/recipes-operations-controller.js"]),
("/api/recipes/calculate", ["static/recipes.html"]),
("/api/recipes/", ["static/recipes.html", "static/js/pages/recipes-operations-controller.js", "static/js/pages/recipes-editor-controller.js"]),
("/api/auth/check", ["static/recipes.html", "static/js/pages/recipes-auth-settings.js"]),
("/api/auth/logout", ["static/js/pages/recipes-auth-settings.js"]),
("/api/auth/change_credentials", ["static/js/pages/recipes-auth-settings.js"]),
("/api/sync/clients", ["static/js/pages/recipes-auth-settings.js"]),
]
# CSS анимации / transition-классы
CSS_ANIMATION_MARKERS: List[Tuple[str, List[str]]] = [
("dashboard-skeleton-shimmer", ["static/css/wesp-recipes-skeleton.css"]),
("dashboard-list-skeleton--exit", ["static/css/wesp-recipes-skeleton.css"]),
("recipe-form--enter", ["static/css/wesp-recipes-editor.css", "static/recipes.html"]),
("dashboard-mobile-step-", ["static/css/wesp-recipes-mobile.css", "static/js/pages/recipes-data-controller.js"]),
]
# Скелетон-templates
SKELETON_TEMPLATE_IDS: List[str] = [
"recipe-card-skeleton-inner",
"recipe-card-skeleton-inner--mill",
"dashboard-list-skeleton-dispensers",
"dashboard-list-skeleton-periods",
]
# Mill-ветки в JS/HTML
MILL_MARKERS: List[Tuple[str, List[str]]] = [
("loadMillRecipes", ["static/js/pages/recipes-data-controller.js"]),
("getCurrentDispenserType() === \"mill\"", [
"static/js/pages/recipes-data-controller.js",
"static/js/pages/recipes-operations-controller.js",
"static/js/pages/recipes-editor-controller.js",
]),
("deleted_ingredient_ids", ["static/recipes.html"]),
("deleted_unloading_group_ids", ["static/recipes.html"]),
("target_component_id", ["static/recipes.html", "static/js/pages/recipes-editor-controller.js"]),
("unlinkFromPeriodOnly", ["static/js/pages/recipes-operations-controller.js"]),
]
# Скрипты, импортируемые из recipes.html (type=module)
RECIPES_PAGE_SCRIPTS: List[str] = [
"recipes-boot.js",
"recipes-page.js",
"recipes-data-controller.js",
"recipes-operations-controller.js",
"recipes-editor-controller.js",
"recipes-auth-settings.js",
"recipe-list-dnd.js",
"recipe-period-transfer.js",
"recipe-mobile-sheet-gestures.js",
]
# Модули, подключаемые через recipes-page.js
RECIPES_PAGE_NESTED_MODULES: List[str] = [
"recipe-editor.js",
"dispenser-selector.js",
"sync-panel.js",
]
+403
View File
@@ -0,0 +1,403 @@
"""Harness: центральный сервер + два терминала (отдельные SQLite) с SyncClient pull/apply/confirm."""
from __future__ import annotations
import os
import socket
import tempfile
import threading
import time
from contextlib import contextmanager
from datetime import datetime
from typing import Any, Dict, Generator, List, Optional, Tuple
from sqlalchemy import select
from werkzeug.serving import make_server
from app import create_app, db
from app.models import (
FeedDispenser,
FeedingPeriod,
Ingredient,
PeriodRecipe,
Recipe,
SyncClient as SyncClientModel,
SyncEngineState,
SyncQueue,
)
from app.services.sync_manager import SNAPSHOT_MODELS, enqueue_sync_queue_task
from config import TestingConfig
from sqlalchemy import func
from sync_client import SyncClient, _attach_local_db_apply, _attach_local_db_push
def _free_port() -> int:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
def _config_for(tmp_dir: str, db_name: str, *, login: str, password: str) -> type:
class _Cfg(TestingConfig):
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(tmp_dir, db_name)}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(tmp_dir, db_name.replace('.db', '_reports.db'))}"}
AUTH_LOGIN = login
AUTH_PASSWORD = password
TESTING = True
return _Cfg
class SyncDualInstanceHarness:
"""Сервер (master DB) + terminal A/B (client DB) в одном тестовом цикле."""
NODE_A = "sync-dual-term-a"
NODE_B = "sync-dual-term-b"
AUTH_LOGIN = "sync-dual-admin"
AUTH_PASSWORD = "sync-dual-secret"
def __init__(self) -> None:
self._tmpdir = tempfile.mkdtemp(prefix="wesp-sync-dual-")
self._server: Any = None
self._server_thread: Optional[threading.Thread] = None
self.server_url = ""
self.server_app = None
self.term_a_app = None
self.term_b_app = None
self.client_a: Optional[SyncClient] = None
self.client_b: Optional[SyncClient] = None
self._ctx_stack: List[Any] = []
def start(self) -> None:
server_cfg = _config_for(
self._tmpdir, "server.db", login=self.AUTH_LOGIN, password=self.AUTH_PASSWORD
)
term_a_cfg = _config_for(
self._tmpdir, "terminal_a.db", login=self.AUTH_LOGIN, password=self.AUTH_PASSWORD
)
term_b_cfg = _config_for(
self._tmpdir, "terminal_b.db", login=self.AUTH_LOGIN, password=self.AUTH_PASSWORD
)
self.server_app = create_app(server_cfg)
self.term_a_app = create_app(term_a_cfg)
self.term_b_app = create_app(term_b_cfg)
for app in (self.server_app, self.term_a_app, self.term_b_app):
ctx = app.app_context()
ctx.push()
self._ctx_stack.append(ctx)
db.create_all()
port = _free_port()
self.server_url = f"http://127.0.0.1:{port}"
self._server = make_server("127.0.0.1", port, self.server_app, threaded=True)
self._server_thread = threading.Thread(target=self._server.serve_forever, daemon=True)
self._server_thread.start()
time.sleep(0.15)
self._register_nodes([self.NODE_A, self.NODE_B])
self._mark_bootstrap_ready([self.NODE_A, self.NODE_B])
self.client_a = self._make_sync_client(
self.term_a_app, self.NODE_A, "Terminal A", attach_push=True
)
self.client_b = self._make_sync_client(
self.term_b_app, self.NODE_B, "Terminal B", attach_push=True
)
def stop(self) -> None:
if self._server is not None:
self._server.shutdown()
self._server = None
for app in (self.server_app, self.term_a_app, self.term_b_app):
if app is not None:
with app.app_context():
db.session.remove()
db.drop_all()
while self._ctx_stack:
self._ctx_stack.pop().pop()
def _register_nodes(self, node_ids: List[str]) -> None:
import requests
for nid in node_ids:
resp = requests.post(
f"{self.server_url}/api/sync/register",
json={"client_id": nid, "client_name": nid},
timeout=10,
)
resp.raise_for_status()
def _mark_bootstrap_ready(self, node_ids: List[str]) -> None:
with self.server_app.app_context():
state = db.session.get(SyncEngineState, 1)
if state is None:
state = SyncEngineState(id=1)
db.session.add(state)
now = datetime.now()
state.universal_bootstrap_completed_at = now
state.universal_bootstrap_cursor = len(SNAPSHOT_MODELS)
for nid in node_ids:
sc = db.session.execute(
select(SyncClientModel).where(SyncClientModel.node_id == nid)
).scalar_one()
sc.personal_snapshot_cursor = len(SNAPSHOT_MODELS)
sc.personal_snapshot_completed_at = now
db.session.commit()
def _make_sync_client(
self,
app: Any,
node_id: str,
name: str,
*,
attach_push: bool = False,
) -> SyncClient:
sc = SyncClient()
sc.server_url = self.server_url
sc.client_id = node_id
sc.client_name = name
sc.role = "client"
sc.config["role"] = "client"
sc._initial_sync_active = False
_attach_local_db_apply(sc, app)
if attach_push:
_attach_local_db_push(sc, app)
return sc
def terminal_test_client(self, which: str):
app = self.term_a_app if which == "a" else self.term_b_app
client = app.test_client()
client.post(
"/api/auth/login",
json={"login": self.AUTH_LOGIN, "password": self.AUTH_PASSWORD},
)
return client
def push_from_terminal(self, which: str) -> Dict[str, Any]:
return self.sync_terminal(which)
def assert_no_duplicate_sync_queue(
self, table_name: str, record_id: str, action: str
) -> None:
with self.server_ctx():
n = int(
db.session.scalar(
select(func.count())
.select_from(SyncQueue)
.where(
SyncQueue.table_name == table_name,
SyncQueue.record_id == record_id,
SyncQueue.action == action,
SyncQueue.status.in_(("pending", "processing")),
)
)
or 0
)
if n > 1:
raise AssertionError(
f"duplicate sync_queue {table_name}/{record_id}/{action}: {n}"
)
def server_test_client(self):
client = self.server_app.test_client()
client.post(
"/api/auth/login",
json={"login": self.AUTH_LOGIN, "password": self.AUTH_PASSWORD},
)
return client
def sync_terminal(self, which: str) -> Dict[str, Any]:
sc = self.client_a if which == "a" else self.client_b
assert sc is not None
return sc.sync_cycle()
def sync_both(self, rounds: int = 3, *, pause_sec: float = 0.05) -> None:
for _ in range(rounds):
self.sync_terminal("a")
self.sync_terminal("b")
if pause_sec:
time.sleep(pause_sec)
def drain_sync(self, *, max_rounds: int = 12, pause_sec: float = 0.08) -> None:
for _ in range(max_rounds):
ra = self.sync_terminal("a")
rb = self.sync_terminal("b")
if (
int(ra.get("pulled") or 0) == 0
and int(rb.get("pulled") or 0) == 0
and not self._pending_universal_tasks()
):
break
time.sleep(pause_sec)
def _pending_universal_tasks(self) -> bool:
with self.server_app.app_context():
row = db.session.scalar(
select(SyncQueue.id)
.where(
SyncQueue.status.in_(("pending", "processing")),
SyncQueue.target_node_id.is_(None),
)
.limit(1)
)
return row is not None
@contextmanager
def server_ctx(self) -> Generator[None, None, None]:
with self.server_app.app_context():
yield
@contextmanager
def terminal_ctx(self, which: str) -> Generator[None, None, None]:
app = self.term_a_app if which == "a" else self.term_b_app
with app.app_context():
yield
def recipe_on_terminal(self, which: str, recipe_id: str) -> Optional[Recipe]:
with self.terminal_ctx(which):
return db.session.get(Recipe, recipe_id)
def active_ingredient_ids(self, which: str, recipe_id: str) -> List[str]:
with self.terminal_ctx(which):
rows = db.session.execute(
select(Ingredient.id).where(
Ingredient.recipe_id == recipe_id,
Ingredient.is_deleted.is_(False),
)
).scalars().all()
return [str(x) for x in rows]
def sync_queue_tasks(self, table_name: str, record_id: str) -> List[SyncQueue]:
with self.server_ctx():
return list(
db.session.execute(
select(SyncQueue)
.where(
SyncQueue.table_name == table_name,
SyncQueue.record_id == record_id,
)
.order_by(SyncQueue.created_at.desc())
)
.scalars()
.all()
)
def period_recipe_link_deleted(
self, which: str, period_id: str, recipe_id: str
) -> bool:
with self.terminal_ctx(which):
row = db.session.get(
PeriodRecipe, {"period_id": period_id, "recipe_id": recipe_id}
)
return row is None or bool(row.is_deleted)
def seed_dispenser_two_periods_two_recipes(
self,
) -> Tuple[str, str, str, str, str]:
"""1 dispenser, period A/B, recipe r1/r2 в A. Sync на оба терминала."""
disp_id = "dual-disp-1"
period_a = "dual-period-a"
period_b = "dual-period-b"
r1, r2 = "dual-r1", "dual-r2"
with self.server_ctx():
db.session.add_all(
[
FeedDispenser(
id=disp_id,
name="Dual D",
farm="F",
operator="O",
type="dispenser",
content_hash="",
),
FeedingPeriod(id=period_a, name="A", dispenser_id=disp_id),
FeedingPeriod(id=period_b, name="B", dispenser_id=disp_id),
Recipe(
id=r1,
name="R1",
heads_per_trip=1,
mixing_time=0,
content_hash="",
),
Recipe(
id=r2,
name="R2",
heads_per_trip=1,
mixing_time=0,
content_hash="",
),
]
)
db.session.flush()
now = datetime.now()
for ord_, rid in enumerate((r1, r2)):
db.session.add(
PeriodRecipe(
period_id=period_a,
recipe_id=rid,
order=ord_,
created_at=now,
created_by="system",
updated_by="system",
)
)
db.session.commit()
for rid in (r1, r2):
enqueue_sync_queue_task("recipe", rid, "create", priority=2, target_node_id=None)
for rid in (r1, r2):
enqueue_sync_queue_task(
"period_recipes",
f"{period_a}:{rid}",
"create",
priority=2,
target_node_id=None,
)
db.session.commit()
self.drain_sync()
return disp_id, period_a, period_b, r1, r2
def period_recipe_order(self, which: str, period_id: str) -> List[str]:
with self.terminal_ctx(which):
rows = db.session.execute(
select(PeriodRecipe.recipe_id)
.where(
PeriodRecipe.period_id == period_id,
PeriodRecipe.is_deleted.is_(False),
)
.order_by(PeriodRecipe.order.asc())
).scalars().all()
return [str(x) for x in rows]
def task_status(self, task_id: str) -> Optional[str]:
with self.server_ctx():
row = db.session.get(SyncQueue, task_id)
return row.status if row else None
def latest_task(
self, table_name: str, record_id: str, action: str
) -> Optional[SyncQueue]:
with self.server_ctx():
return db.session.execute(
select(SyncQueue)
.where(
SyncQueue.table_name == table_name,
SyncQueue.record_id == record_id,
SyncQueue.action == action,
)
.order_by(SyncQueue.created_at.desc())
.limit(1)
).scalar_one_or_none()
def confirm_only(self, which: str, task_ids: List[str]) -> bool:
sc = self.client_a if which == "a" else self.client_b
assert sc is not None
return sc.confirm_tasks(task_ids)
def pull_only(self, which: str, limit: int = 50) -> List[Dict[str, Any]]:
sc = self.client_a if which == "a" else self.client_b
assert sc is not None
return sc.pull_changes(limit)
@@ -0,0 +1,29 @@
"""Общие хелперы для тестов zootech-страниц (не /recipes)."""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from config import TestingConfig
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
STATIC = PROJECT_ROOT / "static"
class ZootechTestConfig(TestingConfig):
_TMP_DIR = tempfile.mkdtemp(prefix="wesp-zootech-test-")
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(_TMP_DIR, 'zootech_test.db')}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(_TMP_DIR, 'reports_test.db')}"}
AUTH_LOGIN = "zootech-test-admin"
AUTH_PASSWORD = "zootech-test-secret"
def drain_response(resp) -> None:
try:
resp.get_data()
finally:
close = getattr(resp, "close", None)
if callable(close):
close()
+26
View File
@@ -0,0 +1,26 @@
"""Helpers so Flask test requests emulate a LAN client.
``require_paired_terminal`` skips pairing when ``request.host`` or
``request.remote_addr`` looks like localhost; the default test client uses
``127.0.0.1``, so pairing would never be enforced without these kwargs.
"""
from __future__ import annotations
from typing import Any
KIOSK_BASE_URL = "http://kiosk.example:5000/"
def kiosk_client_kwargs(**extra: Any) -> dict[str, Any]:
"""Merge ``base_url`` / ``environ_overrides`` for non-localhost requests."""
out: dict[str, Any] = {
"base_url": KIOSK_BASE_URL,
"environ_overrides": {"REMOTE_ADDR": "192.168.1.10"},
}
if "environ_overrides" in extra:
merged = dict(out["environ_overrides"])
merged.update(extra.pop("environ_overrides"))
out["environ_overrides"] = merged
out.update(extra)
return out
+660
View File
@@ -0,0 +1,660 @@
#!/usr/bin/env python3
"""
Интерактивный запуск pytest для WESP с человекочитаемым итогом на русском.
python3 tests/run_tests.py # меню
python3 tests/run_tests.py sync # набор по id
python3 tests/run_tests.py --list
python3 tests/run_tests.py all --cov
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional, Sequence, Tuple
PROJECT_ROOT = Path(__file__).resolve().parent.parent
PYTEST_INI = PROJECT_ROOT / "pytest.ini"
# Наборы с GPIO/HX711: не входят в «all», pytest.ini их игнорирует — снимаем ignore явно.
_HARDWARE_SUITE_IDS = frozenset({"gpio", "hx711"})
# id, заголовок, пути относительно PROJECT_ROOT
SUITES: List[Tuple[str, str, List[str]]] = [
(
"all",
"Все тесты (без GPIO/HX711)",
["tests"],
),
(
"sync",
"Синхронизация",
[
"tests/test_sync_manager.py",
"tests/test_sync_client_api.py",
"tests/test_sync_dual_push_roundtrip.py",
"tests/test_sync_routes.py",
"tests/test_sync_request_parser.py",
"tests/test_sync_error_display.py",
"tests/test_sync_content_hash.py",
"tests/test_recipe_sync_enqueue.py",
"tests/test_sync_integration.py",
"tests/test_daily_plan_sync.py",
"tests/test_setup_guard_config.py",
],
),
(
"admin",
"Админка и auth",
[
"tests/test_admin_panel_access_and_users.py",
"tests/test_admin_llm.py",
"tests/test_admin_llm_tools.py",
"tests/test_admin_network_interfaces.py",
"tests/test_admin_peripheral_monitor.py",
"tests/test_auth_env_only.py",
"tests/test_route_auth_guards.py",
"tests/test_additional_route_guards_and_pagination.py",
],
),
(
"kiosk",
"Киоск и pairing",
[
"tests/test_kiosk_pairing.py",
"tests/test_kiosk_boot.py",
"tests/test_kiosk_full_setup.py",
"tests/test_kiosk_page_hints.py",
"tests/test_public_device_token_guards.py",
"tests/test_scales_routes.py",
"tests/test_scales_reader.py",
],
),
(
"legacy",
"Отчёты и рецепты (legacy compat)",
[
"tests/test_legacy_compat_first_batch.py",
"tests/test_legacy_compat_second_batch.py",
"tests/test_legacy_compat_third_batch.py",
"tests/test_legacy_compat_fourth_batch.py",
"tests/test_legacy_compat_fifth_batch.py",
"tests/test_legacy_compat_sixth_batch.py",
"tests/test_route_response_contracts.py",
"tests/test_recipe_period_transfer.py",
],
),
(
"mill",
"Кормоцех (API, sync, UI)",
[
"tests/test_feed_mill_api.py",
"tests/test_feed_mill_sync.py",
"tests/test_feed_mill_ui.py",
"tests/test_recipe_sync_enqueue.py",
],
),
(
"recipes",
"Страница /recipes (контракты + API soft delete/order)",
[
"tests/test_recipes_ui_contract.py",
"tests/test_recipes_page_served.py",
"tests/test_zootech_html_guard.py",
"tests/test_feed_mill_ui.py",
"tests/test_recipes_soft_delete.py",
"tests/test_recipes_order.py",
"tests/test_recipes_api_edges.py",
"tests/test_recipes_sync_roundtrip.py",
"tests/test_recipe_calculator.py",
"tests/test_recipes_calculate_api.py",
"tests/test_recipe_dry_matter_locked.py",
],
),
(
"recipes-e2e",
"Страница /recipes (Playwright E2E)",
[
"tests/e2e/test_recipes_page.py",
"tests/e2e/test_recipes_dispenser.py",
"tests/e2e/test_recipes_period_transfer.py",
"tests/e2e/test_recipes_settings.py",
"tests/e2e/test_recipes_mobile.py",
"tests/e2e/test_recipes_user_journeys.py",
"tests/e2e/test_recipes_dual_terminal.py",
],
),
(
"recipes-full",
"Recipes: API + dual sync + E2E (полный прогон)",
[
"tests/test_recipes_ui_contract.py",
"tests/test_recipes_page_served.py",
"tests/test_recipes_soft_delete.py",
"tests/test_recipes_order.py",
"tests/test_recipes_api_edges.py",
"tests/test_recipes_sync_roundtrip.py",
"tests/test_recipe_calculator.py",
"tests/test_recipes_calculate_api.py",
"tests/test_recipe_dry_matter_locked.py",
"tests/test_sync_dual_instance.py",
"tests/test_sync_dual_recipes_dispenser.py",
"tests/test_sync_recipe_children_dual.py",
"tests/test_sync_dual_push_roundtrip.py",
"tests/test_sync_dual_concurrent_recipe.py",
"tests/test_sync_dual_reports.py",
"tests/test_sync_dual_offline_catchup.py",
"tests/e2e/test_recipes_page.py",
"tests/e2e/test_recipes_dispenser.py",
"tests/e2e/test_recipes_period_transfer.py",
"tests/e2e/test_recipes_settings.py",
"tests/e2e/test_recipes_mobile.py",
"tests/e2e/test_recipes_user_journeys.py",
"tests/e2e/test_recipes_dual_terminal.py",
],
),
(
"sync-dual",
"Sync: два терминала (term A + term B)",
[
"tests/test_sync_dual_instance.py",
"tests/test_recipes_sync_roundtrip.py",
"tests/test_sync_dual_recipes_dispenser.py",
"tests/test_sync_recipe_children_dual.py",
"tests/test_sync_dual_push_roundtrip.py",
"tests/test_sync_dual_concurrent_recipe.py",
"tests/test_sync_dual_reports.py",
"tests/test_sync_dual_offline_catchup.py",
"tests/e2e/test_daily_plan_operator_sync.py",
],
),
(
"lab-p0",
"Lab p0: health / скелет",
["tests/test_lab_api.py::LabApiTests::test_health"],
),
(
"lab-p1",
"Lab p1: миграция + ETL fixtures",
[
"tests/test_migrated_schema_matches_models.py",
"tests/test_lab_etl_import.py",
],
),
(
"lab-p2",
"Lab p2: calc engine + golden",
[
"tests/test_lab_calc_engine.py",
"tests/test_lab_calc_golden.py",
"tests/test_lab_calc_daily_totals.py",
"tests/test_lab_nutrient_mapping_regression.py",
"tests/test_lab_sv_repair.py",
"tests/test_recipe_calculator.py",
],
),
(
"lab-p3",
"Lab p3: commands + daily_plan regression",
[
"tests/test_lab_commands.py",
"tests/test_lab_profile_resolve.py",
"tests/test_norm_catalog_import.py",
"tests/test_cleanup_legacy_profiles.py",
"tests/test_component_nutrients_save.py",
"tests/test_ration_recalc_service.py",
"tests/test_lab_math_ration_seed.py",
"tests/test_lab_gfe_norms.py",
"tests/test_lab_racion_norms.py",
"tests/test_lab_demo_nutrients_seed.py",
"tests/test_daily_plan_ingredient_weights.py",
],
),
(
"lab-p4",
"Lab p4: API + apply sync (execution only)",
[
"tests/test_lab_api.py",
"tests/test_lab_formulate.py",
"tests/test_lab_formulate_score.py",
"tests/test_lab_racion_norms.py",
"tests/test_lab_feed_groups.py",
"tests/test_lab_apply_sync_enqueue.py",
"tests/test_sync_integration.py",
],
),
(
"lab-p5",
"Lab p5: golden path + ETL",
[
"tests/test_lab_golden_path.py",
"tests/test_lab_etl_import.py",
"tests/test_lab_agrostar_import.py",
"tests/test_lab_import_router.py",
],
),
(
"lab-p6",
"Lab p6: UI contract + components",
[
"tests/test_lab_ui_contract.py",
"tests/test_zootech_k_hub_ui_contract.py",
"tests/test_component_refactor.py",
],
),
(
"lab-p7",
"Lab p7: regression WESP",
["tests/test_lab_regression_wesp.py"],
),
(
"lab",
"Модуль lab: полный набор",
[
"tests/test_lab_calc_engine.py",
"tests/test_lab_calc_golden.py",
"tests/test_lab_nutrient_mapping_regression.py",
"tests/test_lab_gfe_norms.py",
"tests/test_lab_racion_norms.py",
"tests/test_lab_api.py",
"tests/test_lab_commands.py",
"tests/test_lab_demo_nutrients_seed.py",
"tests/test_lab_golden_path.py",
"tests/test_lab_ui_contract.py",
"tests/test_lab_etl_import.py",
"tests/test_lab_apply_sync_enqueue.py",
"tests/test_lab_regression_wesp.py",
"tests/test_component_refactor.py",
],
),
(
"zootech-full",
"Zootech-страницы: API + контракты + E2E",
[
"tests/test_feed_dispensers_page_served.py",
"tests/test_feed_dispensers_ui_contract.py",
"tests/test_components_page_served.py",
"tests/test_components_ui_contract.py",
"tests/test_components_api.py",
"tests/test_reports_page_served.py",
"tests/test_reports_ui_contract.py",
"tests/test_reports_api.py",
"tests/test_feed_consumption_page_served.py",
"tests/test_feed_consumption_ui_contract.py",
"tests/test_sklad_api.py",
"tests/test_unloading_page_served.py",
"tests/test_unloading_ui_contract.py",
"tests/test_unloading_report_flow.py",
"tests/test_zootech_html_guard.py",
"tests/test_notifications_api.py",
"tests/test_feed_quality_rules.py",
"tests/test_feed_quality_evaluator.py",
"tests/test_feed_quality_api.py",
"tests/test_feed_quality_sync.py",
"tests/test_feed_quality_settings_store.py",
"tests/test_feed_quality_settings_api.py",
"tests/test_feed_quality_notify.py",
"tests/test_feed_quality_migration.py",
"tests/test_analytics_services.py",
"tests/test_analytics_api.py",
"tests/test_analytics_ui_contract.py",
"tests/test_analytics_stock_forecast.py",
"tests/test_daily_plan_builder.py",
"tests/test_daily_plan_api.py",
"tests/test_daily_trip_skip_service.py",
"tests/test_daily_trip_skip_api.py",
"tests/test_daily_plan_sync.py",
"tests/test_daily_plan_part_skip_service.py",
"tests/test_daily_plan_part_skip_api.py",
"tests/test_zootech_k_hub_ui_contract.py",
"tests/test_lab_calc_golden.py",
"tests/test_lab_api.py",
"tests/e2e/test_feed_dispensers_page.py",
"tests/e2e/test_components_page.py",
"tests/e2e/test_reports_page.py",
"tests/e2e/test_feed_consumption_page.py",
"tests/e2e/test_unloading_page.py",
"tests/e2e/test_daily_plan_operator_sync.py",
],
),
(
"update",
"OTA / update",
[
"tests/test_update_flow.py",
"tests/test_update_verify.py",
"tests/test_update_notifier_api.py",
"tests/test_update_health_api.py",
"tests/test_update_state_store.py",
"tests/test_post_update_script.py",
"tests/test_auto_update_db.py",
"tests/test_auto_update_runtime.py",
"tests/test_verify_release_deps.py",
"tests/test_materialize_wheelhouse_sdists.py",
],
),
(
"install",
"Установка / setup wizard",
[
"tests/test_setup_wizard.py",
"tests/test_fresh_install.py",
"tests/test_factory_reset.py",
"tests/test_migrated_schema_matches_models.py",
],
),
(
"network",
"Сеть / Pi / kiosk boot",
[
"tests/test_network_settings_api.py",
"tests/test_pi_platform_setup.py",
"tests/test_pi_boot_config.py",
"tests/test_plymouth_theme.py",
"tests/test_system_restart_service.py",
"tests/test_startup_background.py",
"tests/test_startup_gate.py",
"tests/test_traceroute_analyze.py",
],
),
(
"smoke",
"Быстрый smoke",
[
"tests/test_setup_guard_config.py",
"tests/test_sync_integration.py",
"tests/test_auth_env_only.py",
"tests/test_sync_manager.py",
"tests/test_sync_dual_instance.py::SyncDualInstanceTests::test_recipe_create_reaches_both_terminals",
"tests/test_sync_dual_instance.py::SyncDualInstanceTests::test_confirm_waits_for_both_terminals",
],
),
(
"misc",
"Прочее (UI, exports, install deps)",
[
"tests/test_feed_accounting_exports.py",
"tests/test_sklad_metrics.py",
"tests/test_static_offline_assets.py",
"tests/test_zootech_html_guard.py",
"tests/test_html_head_injects.py",
"tests/test_install_deps_offline.py",
],
),
(
"gpio",
"Оборудование: GPIO (только явный запуск, не в «all»)",
["tests/test_gpio_controller.py"],
),
(
"hx711",
"Оборудование: HX711 (только явный запуск, не в «all»)",
["tests/test_hx711_wrapper.py"],
),
(
"calculate",
"Расчёт рецепта: API + алгоритмы",
[
"tests/test_recipes_calculate_api.py",
"tests/test_recipe_calculator.py",
"tests/test_recipe_calculator_unloading.py",
"tests/test_recipe_calculate_pipeline.py",
"tests/test_recipe_dry_matter_locked.py",
],
),
]
_SUMMARY_RE = re.compile(
r"=+\s*(\d+)\s+failed.*?(\d+)\s+passed"
r"|(\d+)\s+passed.*?(\d+)\s+failed"
r"|(\d+)\s+passed"
r"|(\d+)\s+failed",
re.IGNORECASE | re.DOTALL,
)
_FAILED_LINE = re.compile(r"^FAILED\s+(.+?)\s*$", re.MULTILINE)
_ERROR_LINE = re.compile(r"^E\s+(.+?)\s*$", re.MULTILINE)
@dataclass
class FailureInfo:
nodeid: str
test_name: str
file_name: str
reason: str = ""
@dataclass
class RunReport:
title: str
duration_sec: float
passed: int = 0
failed: int = 0
skipped: int = 0
errors: int = 0
exit_code: int = 0
failures: List[FailureInfo] = field(default_factory=list)
raw_tail: str = ""
def _suite_by_id(suite_id: str) -> Optional[Tuple[str, str, List[str]]]:
key = (suite_id or "").strip().lower()
for sid, title, paths in SUITES:
if sid == key:
return sid, title, paths
return None
def _list_suites() -> None:
print("Доступные наборы тестов WESP:\n")
for sid, title, paths in SUITES:
print(f" {sid:10} {title} ({len(paths)} файл(ов))")
print("\nПример: python3 tests/run_tests.py sync")
def _interactive_menu() -> Optional[str]:
print("\nWESP — выбор тестов\n")
items = [(sid, title) for sid, title, _ in SUITES]
for i, (_, title) in enumerate(items, start=1):
print(f" {i}) {title}")
print(" 0) Выход")
try:
choice = input("\nНомер: ").strip()
except (EOFError, KeyboardInterrupt):
print()
return None
if choice in ("0", "q", "quit", "exit"):
return None
if choice.isdigit():
idx = int(choice)
if 1 <= idx <= len(items):
return items[idx - 1][0]
# по id
if _suite_by_id(choice):
return choice.strip().lower()
print("Неизвестный выбор.")
return _interactive_menu()
def _parse_summary(combined: str) -> Tuple[int, int, int]:
passed = failed = skipped = 0
for line in combined.splitlines():
line = line.strip()
if "passed" in line and " in " in line:
m = re.search(r"(\d+)\s+passed", line)
if m:
passed = int(m.group(1))
m = re.search(r"(\d+)\s+failed", line)
if m:
failed = int(m.group(1))
m = re.search(r"(\d+)\s+skipped", line)
if m:
skipped = int(m.group(1))
break
return passed, failed, skipped
def _parse_failures(combined: str) -> List[FailureInfo]:
failures: List[FailureInfo] = []
lines = combined.splitlines()
i = 0
while i < len(lines):
m = _FAILED_LINE.match(lines[i].strip())
if m:
nodeid = m.group(1).strip()
reason = ""
j = i + 1
while j < len(lines) and j < i + 12:
em = _ERROR_LINE.match(lines[j].strip())
if em:
reason = em.group(1).strip()[:200]
break
j += 1
parts = nodeid.split("::")
file_name = parts[0].replace("tests/", "") if parts else nodeid
test_name = parts[-1] if len(parts) > 1 else nodeid
failures.append(
FailureInfo(
nodeid=nodeid,
test_name=test_name,
file_name=file_name,
reason=reason,
)
)
i += 1
return failures
def run_pytest(
suite_id: str,
*,
with_cov: bool = False,
verbose: bool = False,
) -> RunReport:
found = _suite_by_id(suite_id)
if not found:
raise SystemExit(f"Неизвестный набор: {suite_id!r}")
_, title, paths = found
cmd = [sys.executable, "-m", "pytest"]
if PYTEST_INI.is_file():
cmd.extend(["-c", str(PYTEST_INI)])
if suite_id in _HARDWARE_SUITE_IDS:
cmd.extend(["--override-ini", "addopts="])
cmd.extend(paths)
if suite_id == "all":
cmd.extend(["-m", "not e2e"])
if verbose:
cmd.append("-v")
else:
cmd.append("--tb=short")
if with_cov:
cmd.extend(
[
"--cov=app",
"--cov=sync_client",
"--cov-report=term-missing:skip-covered",
]
)
started = time.monotonic()
proc = subprocess.run(
cmd,
cwd=str(PROJECT_ROOT),
capture_output=True,
text=True,
)
duration = time.monotonic() - started
combined = (proc.stdout or "") + "\n" + (proc.stderr or "")
passed, failed, skipped = _parse_summary(combined)
failures = _parse_failures(combined) if proc.returncode != 0 else []
tail = ""
if proc.returncode != 0 and combined.strip():
tail_lines = combined.strip().splitlines()[-30:]
tail = "\n".join(tail_lines)
return RunReport(
title=title,
duration_sec=duration,
passed=passed,
failed=failed,
skipped=skipped,
exit_code=proc.returncode,
failures=failures,
raw_tail=tail,
)
def _print_report(report: RunReport) -> None:
total = report.passed + report.failed + report.skipped
ok = report.exit_code == 0
bar = "" * 40
print(f"\n{bar}")
print(f" {report.title} — итог")
print(bar)
print(f" Время: {report.duration_sec:.1f} с")
if total:
print(f" Всего: {total}")
print(f" Успешно: {report.passed}")
print(f" Провалено: {report.failed}")
if report.skipped:
print(f" Пропущено: {report.skipped}")
print()
print(f" Статус: {'ПРОЙДЕНО' if ok else 'НЕ ПРОЙДЕНО'}")
if report.failures:
print("\n Проваленные тесты:")
print(" " + "" * 38)
for i, f in enumerate(report.failures[:25], start=1):
print(f" {i}. {f.file_name}")
print(f" {f.test_name}")
if f.reason:
print(f" Причина: {f.reason}")
if len(report.failures) > 25:
print(f" … и ещё {len(report.failures) - 25}")
if report.raw_tail and not ok:
print("\n Последние строки лога:")
print(" " + "" * 38)
for line in report.raw_tail.splitlines():
print(f" {line}")
print(f"{bar}\n")
def main(argv: Optional[Sequence[str]] = None) -> int:
parser = argparse.ArgumentParser(description="Запуск тестов WESP с RU-отчётом")
parser.add_argument(
"suite",
nargs="?",
help="id набора: all, sync, admin, kiosk, …",
)
parser.add_argument("--list", action="store_true", help="Список наборов")
parser.add_argument("--cov", action="store_true", help="pytest-cov (если установлен)")
parser.add_argument("-v", "--verbose", action="store_true", help="pytest -v")
args = parser.parse_args(argv)
if args.list:
_list_suites()
return 0
suite_id = args.suite
if not suite_id:
suite_id = _interactive_menu()
if not suite_id:
return 0
try:
report = run_pytest(suite_id, with_cov=args.cov, verbose=args.verbose)
except FileNotFoundError:
print("Не найден python или pytest.", file=sys.stderr)
return 2
_print_report(report)
return report.exit_code
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,95 @@
import os
import tempfile
import unittest
from app import create_app, db
from config import TestingConfig
from tests.kiosk_test_env import kiosk_client_kwargs
class AdditionalRoutesConfig(TestingConfig):
_TMP_DIR = tempfile.mkdtemp(prefix="wesp-additional-routes-tests-")
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(_TMP_DIR, 'recipes_test.db')}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(_TMP_DIR, 'reports_test.db')}"}
AUTH_LOGIN = "extra-admin"
AUTH_PASSWORD = "extra-secret"
KIOSK_ENFORCE_PAIRED_ONLY = True
class AdditionalRouteGuardsAndPaginationTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(AdditionalRoutesConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def _login(self, **client_kwargs) -> None:
resp = self.client.post(
"/api/auth/login",
json={"login": "extra-admin", "password": "extra-secret"},
**client_kwargs,
)
self.assertEqual(resp.status_code, 200)
def test_scales_current_weight_requires_auth(self) -> None:
kw = kiosk_client_kwargs()
unauthorized = self.client.get("/current_weight", **kw)
self.assertEqual(unauthorized.status_code, 401)
# Pair terminal (kiosk flow) and verify access.
self._login(**kw)
self.client.get("/scales", **kw)
pair = self.client.post("/api/kiosk/pair-token", **kw)
self.assertEqual(pair.status_code, 200)
token = pair.get_json().get("token")
self.assertTrue(token)
self.client.get(f"/api/kiosk/pair/confirm?token={token}", **kw)
self.client.post(
"/api/kiosk/pair/confirm",
data={"token": token},
content_type="application/x-www-form-urlencoded",
**kw,
)
authorized = self.client.get("/current_weight", **kw)
self.assertEqual(authorized.status_code, 200)
self.assertIn("weight", authorized.get_json())
def test_reports_loading_supports_pagination_and_validation(self) -> None:
self._login()
invalid = self.client.get("/api/reports/loading?limit=bad")
self.assertEqual(invalid.status_code, 400)
self.assertTrue(invalid.get_json().get("error"))
ok = self.client.get("/api/reports/loading?limit=10&offset=0")
self.assertEqual(ok.status_code, 200)
self.assertIsInstance(ok.get_json(), list)
def test_equipment_and_sklad_support_pagination_and_validation(self) -> None:
self._login()
invalid_equipment = self.client.get("/api/feed_dispensers?offset=nope")
self.assertEqual(invalid_equipment.status_code, 400)
self.assertTrue(invalid_equipment.get_json().get("error"))
ok_equipment = self.client.get("/api/feed_dispensers?limit=5&offset=0")
self.assertEqual(ok_equipment.status_code, 200)
self.assertIsInstance(ok_equipment.get_json(), list)
invalid_sklad = self.client.get("/api/sklad/components?limit=abc")
self.assertEqual(invalid_sklad.status_code, 400)
self.assertTrue(invalid_sklad.get_json().get("error"))
ok_sklad = self.client.get("/api/sklad/components?limit=5&offset=0")
self.assertEqual(ok_sklad.status_code, 200)
self.assertIsInstance(ok_sklad.get_json(), list)
if __name__ == "__main__":
unittest.main()
+529
View File
@@ -0,0 +1,529 @@
import json
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from werkzeug.security import generate_password_hash
from app import create_app, db
from app.models import WebUser
from app.services.admin_llm_activity_log import (
append_admin_llm_activity,
format_llm_log_line_text,
read_admin_llm_activity,
)
from app.services.admin_llm_rate_limit import reset_llm_rate_limit_for_tests
from tests.test_admin_panel_access_and_users import AdminPanelTestConfig
class AdminLLMEnabledConfig(AdminPanelTestConfig):
WESP_ADMIN_LLM_ENABLED = True
class AdminLLMToolsEnabledConfig(AdminLLMEnabledConfig):
WESP_LLM_TOOLS_ENABLED = True
def _openai_chat_response(text: str) -> dict:
return {
"choices": [
{
"message": {"role": "assistant", "content": text},
}
]
}
class AdminLLMEndpointsTests(unittest.TestCase):
def setUp(self) -> None:
reset_llm_rate_limit_for_tests()
self._prev_security_settings_path = os.environ.get("WESP_SECURITY_SETTINGS_PATH")
self._prev_network_diagnostics_path = os.environ.get("WESP_NETWORK_DIAGNOSTICS_PATH")
_sec_dir = tempfile.mkdtemp(prefix="wesp-llm-sec-")
os.environ["WESP_SECURITY_SETTINGS_PATH"] = os.path.join(_sec_dir, "wesp_security_settings.json")
_diag_dir = tempfile.mkdtemp(prefix="wesp-llm-diag-")
os.environ["WESP_NETWORK_DIAGNOSTICS_PATH"] = os.path.join(
_diag_dir, "wesp_network_diagnostics.json"
)
self.app = create_app(AdminLLMEnabledConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
os.makedirs(AdminLLMEnabledConfig.DATA_DIR, exist_ok=True)
db.create_all()
db.session.add(
WebUser(
login="admin",
password_hash=generate_password_hash("admin-secret"),
is_superuser=True,
)
)
db.session.add(
WebUser(
login="operator",
password_hash=generate_password_hash("operator-secret"),
is_superuser=False,
)
)
db.session.commit()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
reset_llm_rate_limit_for_tests()
if self._prev_security_settings_path is None:
os.environ.pop("WESP_SECURITY_SETTINGS_PATH", None)
else:
os.environ["WESP_SECURITY_SETTINGS_PATH"] = self._prev_security_settings_path
if self._prev_network_diagnostics_path is None:
os.environ.pop("WESP_NETWORK_DIAGNOSTICS_PATH", None)
else:
os.environ["WESP_NETWORK_DIAGNOSTICS_PATH"] = self._prev_network_diagnostics_path
def _login(self, login: str, password: str) -> None:
r = self.client.post("/api/auth/login", json={"login": login, "password": password})
self.assertEqual(r.status_code, 200)
def test_operator_forbidden_llm_status(self) -> None:
self._login("operator", "operator-secret")
r = self.client.get("/api/admin/llm/status")
self.assertEqual(r.status_code, 403)
def test_superuser_llm_status_when_disabled_config(self) -> None:
tmp = tempfile.mkdtemp(prefix="wesp-llm-disabled-")
class _Isolated(AdminPanelTestConfig):
BASE_DIR = tmp
DATA_DIR = os.path.join(tmp, "data")
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(tmp, 'recipes_llm_dis.db')}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(tmp, 'reports_llm_dis.db')}"}
app = create_app(_Isolated)
try:
os.makedirs(_Isolated.DATA_DIR, exist_ok=True)
with app.test_client() as c:
with app.app_context():
db.create_all()
db.session.add(
WebUser(
login="admin",
password_hash=generate_password_hash("admin-secret"),
is_superuser=True,
)
)
db.session.commit()
c.post("/api/auth/login", json={"login": "admin", "password": "admin-secret"})
r = c.get("/api/admin/llm/status")
self.assertEqual(r.status_code, 200)
data = r.get_json()
self.assertFalse(data.get("enabled"))
finally:
with app.app_context():
db.session.remove()
db.drop_all()
@patch("app.services.local_llm_client.requests.get")
def test_llm_status_models_ok(self, mock_get) -> None:
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {
"data": [{"id": "qwen2.5-1.5b-instruct-q5_k_m.gguf"}]
}
self._login("admin", "admin-secret")
r = self.client.get("/api/admin/llm/status")
self.assertEqual(r.status_code, 200)
data = r.get_json()
self.assertTrue(data.get("enabled"))
self.assertTrue(data.get("llm_reachable"))
self.assertTrue(data.get("model_present"))
@patch("app.services.local_llm_client.requests.post")
def test_llm_summary_mock_chat(self, mock_post) -> None:
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = _openai_chat_response("Кратко: отклонений нет.")
self._login("admin", "admin-secret")
r = self.client.post(
"/api/admin/llm/summary",
data=json.dumps(
{"date_from": "2026-01-01", "date_to": "2026-01-07", "focus": "both"}
),
content_type="application/json",
)
self.assertEqual(r.status_code, 200)
data = r.get_json()
self.assertEqual(data.get("reply"), "Кратко: отклонений нет.")
mock_post.assert_called_once()
@patch("app.services.local_llm_client.requests.post")
def test_llm_ping_mock(self, mock_post) -> None:
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = _openai_chat_response("ок")
self._login("admin", "admin-secret")
r = self.client.post("/api/admin/llm/ping", json={})
self.assertEqual(r.status_code, 200)
self.assertEqual(r.get_json().get("reply"), "ок")
mock_post.assert_called_once()
@patch("app.services.local_llm_client.requests.post")
def test_llm_chat_mock(self, mock_post) -> None:
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = _openai_chat_response("Здравствуйте.")
self._login("admin", "admin-secret")
r = self.client.post(
"/api/admin/llm/chat",
data=json.dumps(
{"messages": [{"role": "user", "content": "Привет"}]}
),
content_type="application/json",
)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.get_json().get("reply"), "Здравствуйте.")
mock_post.assert_called_once()
body = (mock_post.call_args.kwargs or {}).get("json") or {}
self.assertNotIn("tools", body)
@patch("app.services.local_llm_client.requests.post")
def test_llm_chat_sends_tools_when_globally_enabled(self, mock_post) -> None:
tmp = tempfile.mkdtemp(prefix="wesp-llm-tools-on-")
class _Cfg(AdminPanelTestConfig):
BASE_DIR = tmp
DATA_DIR = os.path.join(tmp, "data")
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(tmp, 'r_tools.db')}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(tmp, 'rep_tools.db')}"}
WESP_ADMIN_LLM_ENABLED = True
WESP_LLM_TOOLS_ENABLED = True
app = create_app(_Cfg)
try:
os.makedirs(_Cfg.DATA_DIR, exist_ok=True)
with app.test_client() as c:
with app.app_context():
db.create_all()
db.session.add(
WebUser(
login="admin",
password_hash=generate_password_hash("admin-secret"),
is_superuser=True,
)
)
db.session.commit()
c.post("/api/auth/login", json={"login": "admin", "password": "admin-secret"})
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = _openai_chat_response("Ок.")
r = c.post(
"/api/admin/llm/chat",
data=json.dumps({"messages": [{"role": "user", "content": "Тест"}]}),
content_type="application/json",
)
self.assertEqual(r.status_code, 200)
body = (mock_post.call_args.kwargs or {}).get("json") or {}
self.assertIn("tools", body)
self.assertTrue(body.get("tools"))
finally:
with app.app_context():
db.session.remove()
db.drop_all()
def test_llm_summary_503_when_disabled(self) -> None:
tmp = tempfile.mkdtemp(prefix="wesp-llm-sum503-")
class _Iso(AdminPanelTestConfig):
BASE_DIR = tmp
DATA_DIR = os.path.join(tmp, "data")
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(tmp, 'r503.db')}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(tmp, 'rep503.db')}"}
WESP_ADMIN_LLM_ENABLED = False
app = create_app(_Iso)
try:
os.makedirs(_Iso.DATA_DIR, exist_ok=True)
with app.test_client() as c:
with app.app_context():
db.create_all()
db.session.add(
WebUser(
login="admin",
password_hash=generate_password_hash("admin-secret"),
is_superuser=True,
)
)
db.session.commit()
c.post("/api/auth/login", json={"login": "admin", "password": "admin-secret"})
r = c.post(
"/api/admin/llm/summary",
data=json.dumps(
{"date_from": "2026-01-01", "date_to": "2026-01-02", "focus": "both"}
),
content_type="application/json",
)
self.assertEqual(r.status_code, 503)
finally:
with app.app_context():
db.session.remove()
db.drop_all()
class AdminLLMToolsChatTests(unittest.TestCase):
"""Чат с slim-tools: многошаговой ответ LLM (два POST при tool_calls)."""
def setUp(self) -> None:
reset_llm_rate_limit_for_tests()
self._prev_security_settings_path = os.environ.get("WESP_SECURITY_SETTINGS_PATH")
self._prev_network_diagnostics_path = os.environ.get("WESP_NETWORK_DIAGNOSTICS_PATH")
_sec_dir = tempfile.mkdtemp(prefix="wesp-llm-tools-sec-")
os.environ["WESP_SECURITY_SETTINGS_PATH"] = os.path.join(_sec_dir, "wesp_security_settings.json")
_diag_dir = tempfile.mkdtemp(prefix="wesp-llm-tools-diag-")
os.environ["WESP_NETWORK_DIAGNOSTICS_PATH"] = os.path.join(
_diag_dir, "wesp_network_diagnostics.json"
)
self.app = create_app(AdminLLMToolsEnabledConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
os.makedirs(AdminLLMToolsEnabledConfig.DATA_DIR, exist_ok=True)
db.create_all()
db.session.add(
WebUser(
login="admin",
password_hash=generate_password_hash("admin-secret"),
is_superuser=True,
)
)
db.session.commit()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
reset_llm_rate_limit_for_tests()
if self._prev_security_settings_path is None:
os.environ.pop("WESP_SECURITY_SETTINGS_PATH", None)
else:
os.environ["WESP_SECURITY_SETTINGS_PATH"] = self._prev_security_settings_path
if self._prev_network_diagnostics_path is None:
os.environ.pop("WESP_NETWORK_DIAGNOSTICS_PATH", None)
else:
os.environ["WESP_NETWORK_DIAGNOSTICS_PATH"] = self._prev_network_diagnostics_path
def _login(self) -> None:
r = self.client.post("/api/auth/login", json={"login": "admin", "password": "admin-secret"})
self.assertEqual(r.status_code, 200)
@patch("app.services.local_llm_client.requests.post")
def test_llm_chat_tool_loop_sql_preview(self, mock_post) -> None:
def _post(url: str, **kwargs: object) -> MagicMock:
body = kwargs.get("json") or {}
msgs = body.get("messages") or []
has_tool = any(
isinstance(m, dict) and m.get("role") == "tool" for m in msgs
)
m = MagicMock()
m.status_code = 200
if not has_tool:
m.json.return_value = {
"choices": [
{
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "slim_sql",
"arguments": json.dumps(
{
"bind": "recipes",
"sql": "SELECT 1 AS one",
"mode": "preview",
}
),
},
}
],
}
}
]
}
else:
m.json.return_value = {
"choices": [
{"message": {"role": "assistant", "content": "План запроса получен."}}
]
}
return m
mock_post.side_effect = _post
self._login()
r = self.client.post(
"/api/admin/llm/chat",
data=json.dumps({"messages": [{"role": "user", "content": "Покажи план для SELECT 1"}]}),
content_type="application/json",
)
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
data = r.get_json() or {}
self.assertIn("План", data.get("reply", ""))
self.assertEqual(mock_post.call_count, 2)
trace = data.get("tool_trace") or []
self.assertTrue(trace)
self.assertEqual(trace[0].get("tool"), "slim_sql")
self.assertTrue(trace[0].get("ok"))
@patch("app.services.local_llm_client.requests.post")
def test_llm_chat_sql_execute_blocked_when_sql_mode_preview(self, mock_post) -> None:
def _post(url: str, **kwargs: object) -> MagicMock:
body = kwargs.get("json") or {}
msgs = body.get("messages") or []
has_tool = any(
isinstance(m, dict) and m.get("role") == "tool" for m in msgs
)
m = MagicMock()
m.status_code = 200
if not has_tool:
m.json.return_value = {
"choices": [
{
"message": {
"role": "assistant",
"tool_calls": [
{
"id": "c1",
"type": "function",
"function": {
"name": "slim_sql",
"arguments": json.dumps(
{
"bind": "recipes",
"sql": "SELECT 1 AS x",
"mode": "execute",
}
),
},
}
],
}
}
]
}
else:
m.json.return_value = {
"choices": [{"message": {"role": "assistant", "content": "Ок."}}]
}
return m
mock_post.side_effect = _post
self._login()
r = self.client.post(
"/api/admin/llm/chat",
data=json.dumps(
{
"messages": [{"role": "user", "content": "Данные"}],
"sql_mode": "preview",
"tools_enabled": True,
}
),
content_type="application/json",
)
self.assertEqual(r.status_code, 200)
trace = (r.get_json() or {}).get("tool_trace") or []
self.assertTrue(trace)
self.assertFalse(trace[0].get("ok"))
class AdminLLMRateLimitTests(unittest.TestCase):
"""Второй POST llm/summary подряд должен получить 429 при ненулевом интервале."""
def setUp(self) -> None:
reset_llm_rate_limit_for_tests()
self._prev_security_settings_path = os.environ.get("WESP_SECURITY_SETTINGS_PATH")
self._prev_network_diagnostics_path = os.environ.get("WESP_NETWORK_DIAGNOSTICS_PATH")
_sec_dir = tempfile.mkdtemp(prefix="wesp-llm-rl-")
os.environ["WESP_SECURITY_SETTINGS_PATH"] = os.path.join(_sec_dir, "wesp_security_settings.json")
_diag_dir = tempfile.mkdtemp(prefix="wesp-llm-rl-d-")
os.environ["WESP_NETWORK_DIAGNOSTICS_PATH"] = os.path.join(
_diag_dir, "wesp_network_diagnostics.json"
)
class R(AdminPanelTestConfig):
WESP_ADMIN_LLM_ENABLED = True
WESP_ADMIN_LLM_RATE_LIMIT_SEC = 90.0
self.app = create_app(R)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
os.makedirs(R.DATA_DIR, exist_ok=True)
db.create_all()
db.session.add(
WebUser(
login="admin",
password_hash=generate_password_hash("admin-secret"),
is_superuser=True,
)
)
db.session.commit()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
reset_llm_rate_limit_for_tests()
if self._prev_security_settings_path is None:
os.environ.pop("WESP_SECURITY_SETTINGS_PATH", None)
else:
os.environ["WESP_SECURITY_SETTINGS_PATH"] = self._prev_security_settings_path
if self._prev_network_diagnostics_path is None:
os.environ.pop("WESP_NETWORK_DIAGNOSTICS_PATH", None)
else:
os.environ["WESP_NETWORK_DIAGNOSTICS_PATH"] = self._prev_network_diagnostics_path
@patch("app.services.local_llm_client.requests.post")
def test_second_summary_returns_429(self, mock_post) -> None:
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = _openai_chat_response("x")
self.client.post("/api/auth/login", json={"login": "admin", "password": "admin-secret"})
body = json.dumps({"date_from": "2026-01-01", "date_to": "2026-01-07", "focus": "both"})
r1 = self.client.post("/api/admin/llm/summary", data=body, content_type="application/json")
self.assertEqual(r1.status_code, 200)
r2 = self.client.post("/api/admin/llm/summary", data=body, content_type="application/json")
self.assertEqual(r2.status_code, 429)
self.assertIn("Подождите", r2.get_json().get("message", ""))
self.assertEqual(mock_post.call_count, 1)
class AdminLLMActivityLogTests(unittest.TestCase):
def test_append_read_and_format(self) -> None:
tmp = tempfile.mkdtemp(prefix="wesp-llm-log-")
log_path = os.path.join(tmp, "wesp-llm.jsonl")
class _App:
config = {
"TESTING": False,
"WESP_ADMIN_LLM_LOG_PATH": log_path,
"BASE_DIR": tmp,
"WESP_ADMIN_LLM_LOG_RETENTION_DAYS": 7,
}
app = _App()
append_admin_llm_activity(
app,
event="chat",
level="ok",
user_login="admin",
detail="ctx=light tools=on",
duration_ms=120,
meta={"tool_steps": 2},
)
rows, path = read_admin_llm_activity(app, max_lines=10)
self.assertTrue(os.path.isfile(path))
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].get("event"), "chat")
self.assertEqual(rows[0].get("user"), "admin")
text = format_llm_log_line_text(rows[0])
self.assertIn("llm/chat", text)
self.assertIn("120 ms", text)
+182
View File
@@ -0,0 +1,182 @@
"""Юнит-тесты slim-tools (без вызова LLM)."""
from __future__ import annotations
import unittest
from app.services.admin_llm_tool_router import _validate_select_only
from app.services.admin_llm_orchestrator import (
_auto_probe_plan,
_db_glimpse_table_list_only,
_extract_dispenser_hint,
_is_components_question,
_looks_unreliable_final,
_structured_answer_from_trace,
)
class AdminLlmSqlPolicyTests(unittest.TestCase):
def test_accepts_simple_select(self) -> None:
s = _validate_select_only(" SELECT 1 AS x ")
self.assertIn("SELECT", s.upper())
def test_accepts_with_cte(self) -> None:
s = _validate_select_only("WITH a AS (SELECT 1) SELECT * FROM a")
self.assertTrue(s.upper().startswith("WITH"))
def test_rejects_semicolon_two_statements(self) -> None:
with self.assertRaises(ValueError):
_validate_select_only("SELECT 1; SELECT 2")
def test_rejects_insert(self) -> None:
with self.assertRaises(ValueError):
_validate_select_only("INSERT INTO t VALUES (1)")
def test_rejects_pragma(self) -> None:
with self.assertRaises(ValueError):
_validate_select_only("PRAGMA integrity_check")
class AdminLlmOrchestratorHelpersTests(unittest.TestCase):
def test_extract_dispenser_hint_after_marker(self) -> None:
self.assertEqual(
_extract_dispenser_hint("какие есть для кормораздатчика лох"),
"лох",
)
def test_extract_dispenser_hint_empty_for_list_in_db(self) -> None:
self.assertEqual(_extract_dispenser_hint("какие кормораздатчики есть в бд?"), "")
def test_extract_dispenser_hint_quoted(self) -> None:
self.assertEqual(_extract_dispenser_hint('рецепты для «шкаф А»'), "шкаф А")
def test_auto_probe_includes_distinct_recipes_when_named_feeder_without_word_recipe(self) -> None:
plan = _auto_probe_plan("какие есть для кормораздатчика лох")
sqls = [a[1].get("sql", "") for a in plan if a[0] == "slim_sql"]
self.assertTrue(any("DISTINCT r.name AS recipe" in s and "лох" in s for s in sqls))
def test_components_question_typo_still_detected(self) -> None:
self.assertTrue(_is_components_question("кикие есть компонентв".lower(), "кикие есть компонентв"))
def test_auto_probe_db_glimpse_runs_sqlite_master(self) -> None:
plan = _auto_probe_plan("ок, а что есть бд?")
sqls = [a[1].get("sql", "") for a in plan if a[0] == "slim_sql"]
self.assertTrue(any("sqlite_master" in s for s in sqls))
def test_db_glimpse_table_list_skips_feeder_questions(self) -> None:
self.assertTrue(_db_glimpse_table_list_only("что есть в бд"))
self.assertFalse(_db_glimpse_table_list_only("какие кормораздатчики есть в бд"))
def test_looks_unreliable_detects_hallucinated_db_files(self) -> None:
self.assertTrue(
_looks_unreliable_final(
"что в базе",
"В recipes.db хранятся рецепты, в reports.db — отчёты.",
)
)
def test_structured_feeders_not_replaced_by_table_catalog(self) -> None:
trace = [
{
"tool": "slim_sql",
"ok": True,
"result_preview": {
"rows_sample": [{"name": "recipe"}, {"name": "feed_dispenser"}],
"row_keys": ["name"],
},
},
{
"tool": "slim_sql",
"ok": True,
"result_preview": {
"rows_sample": [
{"id": "1", "name": "Шкаф 1", "farm": "Ферма А", "is_active": 1},
],
"row_keys": ["farm", "id", "is_active", "name"],
},
},
]
out = _structured_answer_from_trace("какие кормораздатчики есть в бд?", trace)
self.assertIn("Шкаф 1", out)
self.assertTrue(out.startswith("Кормораздатчики"))
def test_auto_probe_includes_component_selects(self) -> None:
plan = _auto_probe_plan("какие компоненты в бд")
sqls = [a[1].get("sql", "") for a in plan if a[0] == "slim_sql"]
self.assertTrue(any("pragma_table_info('component')" in s for s in sqls))
self.assertTrue(any("FROM component" in s and "dry_matter" in s for s in sqls))
self.assertTrue(any("sqlite_master" in s for s in sqls))
def test_structured_answer_prefers_component_rows_not_sqlite_master(self) -> None:
trace = [
{
"tool": "slim_sql",
"ok": True,
"result_preview": {
"rows_sample": [{"name": "recipe"}, {"name": "component"}],
"row_keys": ["name"],
},
},
{
"tool": "slim_sql",
"ok": True,
"result_preview": {
"rows_sample": [
{
"name": "Соя",
"type": "кг",
"is_active": 1,
"is_deleted": 0,
"dry_matter": 1.0,
"protein": 2.0,
"energy": 3.0,
"price": 0.0,
}
],
"row_keys": [
"dry_matter",
"energy",
"is_active",
"is_deleted",
"name",
"price",
"protein",
"type",
],
},
},
]
out = _structured_answer_from_trace("что в таблице component", trace)
self.assertIn("Соя", out)
self.assertNotIn("alembic", out)
def test_structured_answer_uses_recipe_column_not_dispenser_names(self) -> None:
trace = [
{
"tool": "slim_sql",
"ok": True,
"result_preview": {
"rows_sample": [
{"dispenser": "лох", "farm": "X", "operator": "Иванов", "period": "утро", "recipe": "Рацион А"},
{"dispenser": "другой", "farm": "Y", "operator": "", "period": "утро", "recipe": "Рацион Б"},
],
"row_keys": ["dispenser", "farm", "operator", "period", "recipe"],
},
},
{
"tool": "slim_sql",
"ok": True,
"result_preview": {
"rows_sample": [{"id": "1", "name": "лох", "farm": "X", "is_active": 1}],
"row_keys": ["farm", "id", "is_active", "name"],
},
},
]
out = _structured_answer_from_trace("рецепты для кормораздатчика лох", trace)
self.assertIn("Рацион А", out)
self.assertNotIn("Иванов", out)
self.assertNotIn("Рацион Б", out)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,44 @@
"""Эвристика «физических» сетевых интерфейсов для дашборда."""
import importlib.util
import sys
import unittest
from pathlib import Path
_ROOT = Path(__file__).resolve().parents[1]
_SPEC = importlib.util.spec_from_file_location(
"wesp_admin_system_metrics_testonly",
_ROOT / "app" / "services" / "admin_system_metrics.py",
)
assert _SPEC and _SPEC.loader
_mod = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(_mod)
network_interface_is_physical = _mod.network_interface_is_physical
class NetworkInterfacePhysicalTests(unittest.TestCase):
def test_always_treated_as_virtual(self) -> None:
self.assertFalse(network_interface_is_physical("lo"))
self.assertFalse(network_interface_is_physical("lo0"))
self.assertFalse(network_interface_is_physical("docker0"))
self.assertFalse(network_interface_is_physical("veth0abc1"))
self.assertFalse(network_interface_is_physical("br-4e2d9a"))
self.assertFalse(network_interface_is_physical("br0"))
self.assertFalse(network_interface_is_physical("virbr0"))
@unittest.skipUnless(sys.platform.startswith("linux"), "имена eth*/wlan* — ветка Linux")
def test_linux_common_nic_names_match_regex_fallback(self) -> None:
self.assertTrue(network_interface_is_physical("eth0"))
self.assertTrue(network_interface_is_physical("enp2s0"))
self.assertTrue(network_interface_is_physical("wlan0"))
self.assertTrue(network_interface_is_physical("bond0"))
self.assertTrue(network_interface_is_physical("team0"))
@unittest.skipUnless(sys.platform == "darwin", "на macOS физические в основном en*")
def test_darwin_en(self) -> None:
self.assertTrue(network_interface_is_physical("en0"))
self.assertFalse(network_interface_is_physical("bridge0"))
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,256 @@
import os
import tempfile
import time
import unittest
from unittest.mock import MagicMock, patch
from app import create_app, db
from app.services.admin_peripheral_monitor import (
append_peripheral_event,
build_hardware_status,
clear_peripheral_events,
diagnostics_hardware_block,
read_peripheral_events,
reset_debounce_state_for_tests,
)
from app.services.admin_system_metrics import _detect_root_disk_media
from config import TestingConfig
class PeripheralMonitorConfig(TestingConfig):
AUTH_LOGIN = "periph-admin"
AUTH_PASSWORD = "periph-secret"
WESP_PERIPHERAL_EVENT_DEBOUNCE_SEC = 60
SIMULATION_MODE = True
class AdminPeripheralMonitorTests(unittest.TestCase):
def setUp(self) -> None:
reset_debounce_state_for_tests()
self._tmp_dir = tempfile.mkdtemp(prefix="wesp-periph-monitor-")
log_path = os.path.join(self._tmp_dir, "peripherals.jsonl")
class _Cfg(PeripheralMonitorConfig):
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(self._tmp_dir, 'recipes_test.db')}"
SQLALCHEMY_BINDS = {
"reports": f"sqlite:///{os.path.join(self._tmp_dir, 'reports_test.db')}"
}
WESP_ADMIN_PERIPHERAL_LOG_PATH = log_path
self.app = create_app(_Cfg)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
def tearDown(self) -> None:
reset_debounce_state_for_tests()
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_append_and_read_events(self) -> None:
append_peripheral_event(
self.app,
component="hx711",
code="hx711_read_error",
level="err",
message="test error",
force=True,
)
events, path = read_peripheral_events(self.app, limit=10)
self.assertTrue(path)
self.assertEqual(len(events), 1)
self.assertEqual(events[0]["code"], "hx711_read_error")
def test_clear_peripheral_events(self) -> None:
append_peripheral_event(
self.app,
component="hx711",
code="hx711_read_error",
message="to clear",
force=True,
)
events, _ = read_peripheral_events(self.app, limit=10)
self.assertEqual(len(events), 1)
ok, path = clear_peripheral_events(self.app)
self.assertTrue(ok)
self.assertTrue(path)
events, _ = read_peripheral_events(self.app, limit=10)
self.assertEqual(events, [])
def test_admin_peripheral_events_clear_api(self) -> None:
append_peripheral_event(
self.app,
component="gpio",
code="gpio_blink",
message="blink",
force=True,
)
self.client = self.app.test_client()
login = self.client.post(
"/api/auth/login",
json={"login": "periph-admin", "password": "periph-secret"},
)
self.assertEqual(login.status_code, 200)
r = self.client.delete("/api/admin/peripheral-events")
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
self.assertEqual(r.get_json().get("status"), "success")
events, _ = read_peripheral_events(self.app, limit=10)
self.assertEqual(events, [])
def test_read_excludes_network_component(self) -> None:
append_peripheral_event(
self.app,
component="network",
code="mdns_started",
message="net",
force=True,
)
append_peripheral_event(
self.app,
component="hx711",
code="hx711_read_error",
message="hw",
force=True,
)
events, _ = read_peripheral_events(self.app, limit=10, exclude_component="network")
self.assertEqual(len(events), 1)
self.assertEqual(events[0].get("component"), "hx711")
def test_clear_excludes_network_component(self) -> None:
append_peripheral_event(
self.app,
component="network",
code="mdns_started",
message="net",
force=True,
)
append_peripheral_event(
self.app,
component="gpio",
code="gpio_blink",
message="blink",
force=True,
)
ok, _ = clear_peripheral_events(self.app, exclude_component="network")
self.assertTrue(ok)
net_events, _ = read_peripheral_events(self.app, limit=10, component="network")
hw_events, _ = read_peripheral_events(self.app, limit=10, exclude_component="network")
self.assertEqual(len(net_events), 1)
self.assertEqual(net_events[0].get("code"), "mdns_started")
self.assertEqual(hw_events, [])
def test_debounce_skips_duplicate(self) -> None:
self.app.config["WESP_PERIPHERAL_EVENT_DEBOUNCE_SEC"] = 120
append_peripheral_event(
self.app,
component="hx711",
code="hx711_read_error",
message="first",
force=True,
)
append_peripheral_event(
self.app,
component="hx711",
code="hx711_read_error",
message="second",
)
events, _ = read_peripheral_events(self.app, limit=10)
self.assertEqual(len(events), 1)
self.assertEqual(events[0]["message"], "first")
def test_diagnostics_hardware_block_simulation(self) -> None:
from app.models import HardwareSetting
from app.routes import scales as scales_module
if scales_module._scales_reader is not None:
scales_module._scales_reader.stop()
scales_module._scales_reader = None
db.session.add(
HardwareSetting(
id=1,
counts_per_kg=1.0,
tare_raw=0.0,
simulation_mode=True,
simulation_weight_kg=0.0,
)
)
db.session.commit()
block = diagnostics_hardware_block(self.app)
self.assertTrue(block["simulation_mode"])
self.assertTrue(block["hx711_driver_ready"] or block["drivers_absent"] is False)
def test_build_hardware_status_structure(self) -> None:
mock_reader = MagicMock()
mock_reader.get_scale_health.return_value = {
"simulation_mode": True,
"hx711_ok": True,
"hx711_error": None,
}
mock_reader.get_scale_debug_snapshot.return_value = {
"counts_per_kg": 1.0,
"tare_raw": 0.0,
"raw_history_len": 5,
"last_raw": 10.0,
"raw_min_recent": 8.0,
"raw_max_recent": 12.0,
"error_streak": 0,
"last_success_at": time.time(),
}
mock_reader.get_current_weight.return_value = 3
mock_reader.get_last_hx711_error.return_value = None
mock_reader.get_simulation_state.return_value = {
"simulation_mode": True,
"simulation_weight_kg": 0.0,
}
with patch("app.routes.scales._get_reader", return_value=mock_reader):
payload = build_hardware_status(self.app)
self.assertIn("machine", payload)
self.assertIn("host_alerts", payload)
self.assertIn("peripherals", payload)
self.assertTrue(payload["scales"]["available"])
self.assertEqual(payload["scales"]["weight_kg"], 3)
def test_detect_root_disk_media_no_name_error(self) -> None:
try:
label = _detect_root_disk_media()
except NameError as exc:
self.fail(f"_detect_root_disk_media raised NameError: {exc}")
self.assertIsInstance(label, str)
def test_admin_hardware_status_api(self) -> None:
from app.routes import scales as scales_module
reader = getattr(scales_module, "_scales_reader", None)
if reader is not None:
reader.stop()
scales_module._scales_reader = None
self.client = self.app.test_client()
login = self.client.post(
"/api/auth/login",
json={"login": "periph-admin", "password": "periph-secret"},
)
self.assertEqual(login.status_code, 200)
r = self.client.get("/api/admin/hardware-status")
self.assertEqual(r.status_code, 200)
body = r.get_json()
self.assertEqual(body.get("status"), "success")
self.assertIn("scales", body)
self.assertIn("peripherals", body)
ev = self.client.get("/api/admin/peripheral-events?limit=5")
self.assertEqual(ev.status_code, 200)
self.assertIn("events", ev.get_json())
summary = self.client.get("/api/admin/summary")
self.assertEqual(summary.status_code, 200)
hw = summary.get_json().get("hardware") or {}
self.assertIn("simulation_mode", hw)
if __name__ == "__main__":
unittest.main()
+186
View File
@@ -0,0 +1,186 @@
"""API /api/analytics."""
from __future__ import annotations
import unittest
from datetime import datetime
from app import create_app, db
from app.models import Component, LoadingReport, LoadingReportComponent, Recipe
from app.services.setup_state import mark_setup_complete
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class AnalyticsApiTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
self.client.post(
"/api/auth/login",
json={"login": "zootech-test-admin", "password": "zootech-test-secret"},
)
db.session.add(
Component(
id="api-an-c1",
name="Силос",
type="forage",
dry_matter=30.0,
protein=0.0,
energy=0.0,
price=3.0,
)
)
db.session.add(
Recipe(id="api-an-r1", name="Рейс", heads_per_trip=1, content_hash="")
)
report = LoadingReport(
id="api-an-lr",
recipe_id="api-an-r1",
recipe_name="Рейс",
start_time=datetime.now(),
target_mixing_time=60,
actual_mixing_time=60,
total_weight=100.0,
created_by="system",
updated_by="system",
)
db.session.add(report)
db.session.flush()
db.session.add(
LoadingReportComponent(
report_id=report.id,
component_id="api-an-c1",
component_name="Силос",
target_weight=100.0,
actual_weight=150.0,
overload=50.0,
loading_order=1,
created_by="system",
updated_by="system",
)
)
db.session.commit()
self.today = datetime.now().strftime("%Y-%m-%d")
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_finance_endpoint(self) -> None:
resp = self.client.get(
f"/api/analytics/finance?date_from={self.today}&date_to={self.today}"
)
self.assertEqual(resp.status_code, 200)
body = resp.get_json()
self.assertEqual(body["overloadRub"], 150.0)
self.assertIn("dominantIssue", body)
self.assertEqual(body["dominantIssue"], "overload")
def test_plan_fact_endpoint(self) -> None:
resp = self.client.get(
f"/api/analytics/plan-fact?date_from={self.today}&date_to={self.today}"
)
self.assertEqual(resp.status_code, 200)
body = resp.get_json()
self.assertEqual(body["reportCount"], 1)
self.assertEqual(body["items"][0]["loadingReportId"], "api-an-lr")
def test_stock_forecast_endpoint(self) -> None:
resp = self.client.get("/api/analytics/stock-forecast")
self.assertEqual(resp.status_code, 200)
body = resp.get_json()
self.assertIn("items", body)
self.assertIn("alertBanner", body)
def test_finance_export_three_sheets(self) -> None:
resp = self.client.get(
f"/api/analytics/finance/export?date_from={self.today}&date_to={self.today}"
)
self.assertEqual(resp.status_code, 200)
from openpyxl import load_workbook
import io
wb = load_workbook(io.BytesIO(resp.data))
self.assertEqual(set(wb.sheetnames), {"Итоги", "Сравнение", "Подробно"})
def test_analytics_export_xlsx(self) -> None:
resp = self.client.get(
f"/api/analytics/export?format=xlsx&date_from={self.today}&date_to={self.today}"
)
self.assertEqual(resp.status_code, 200)
self.assertIn("spreadsheetml", resp.content_type or resp.mimetype or "")
def test_analytics_export_comparison_sheet_layout(self) -> None:
resp = self.client.get(
f"/api/analytics/export?format=xlsx&section=comparison&date_from={self.today}&date_to={self.today}"
)
self.assertEqual(resp.status_code, 200)
from openpyxl import load_workbook
from openpyxl.utils import get_column_letter
import io
wb = load_workbook(io.BytesIO(resp.data))
self.assertEqual(wb.sheetnames, ["Сравнение"])
ws = wb["Сравнение"]
headers = [cell.value for cell in ws[1]]
self.assertIn("Разница План/Факт, кг", headers)
self.assertNotIn("Факс", "".join(str(h) for h in headers if h))
for header in (
"По рецепту (База), кг",
"На сегодня (План), кг",
"Разница База/Факт, кг",
"Разница План/Факт, кг",
):
col_idx = headers.index(header) + 1
width = ws.column_dimensions[get_column_letter(col_idx)].width
self.assertGreaterEqual(width, 18, msg=f"Column {header} too narrow: {width}")
self.assertGreater(len(ws.conditional_formatting), 0)
def test_analytics_export_pdf(self) -> None:
resp = self.client.get(
f"/api/analytics/export?format=pdf&date_from={self.today}&date_to={self.today}"
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content_type, "application/pdf")
self.assertTrue(resp.data.startswith(b"%PDF"))
def test_analytics_export_summary_only_xlsx(self) -> None:
resp = self.client.get(
f"/api/analytics/export?format=xlsx&section=summary&date_from={self.today}&date_to={self.today}"
)
self.assertEqual(resp.status_code, 200)
from openpyxl import load_workbook
import io
wb = load_workbook(io.BytesIO(resp.data))
self.assertEqual(wb.sheetnames, ["Итоги"])
ws = wb["Итоги"]
header_row = next(
row[0].row
for row in ws.iter_rows(min_row=1, max_col=1)
if row[0].value == "Ферма"
)
header_values = [cell.value for cell in ws[header_row]]
self.assertIn("Ферма", header_values)
self.assertIn("Перерасход, ₽", header_values)
net_row = next(
row[0].row
for row in ws.iter_rows(min_row=1, max_col=1)
if row[0].value == "Итого по деньгам, ₽"
)
net_value = ws.cell(row=net_row, column=2).value
self.assertGreaterEqual(float(net_value or 0), 0)
hint = ws.cell(row=net_row + 1, column=1).value
self.assertIn("перерасход", str(hint).lower())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,73 @@
"""parse_export_sections для analytics export."""
from __future__ import annotations
import unittest
from app.services.analytics.export_context import net_rub_display, net_rub_dominant_hint
from app.services.analytics.pdf_export import (
_COMPARISON_COL_WIDTHS,
_DETAIL_COL_PCT,
_DETAIL_COL_WIDTHS,
_USABLE_WIDTH,
_format_rub,
_format_rub_plain,
_widths_from_percentages,
)
from app.services.analytics.export_sections import (
export_filename_base,
parse_export_sections,
)
class ExportSectionsTests(unittest.TestCase):
def test_all_by_default(self) -> None:
self.assertEqual(
parse_export_sections(None),
frozenset({"summary", "comparison", "reports"}),
)
self.assertEqual(parse_export_sections("all"), parse_export_sections(None))
def test_single_section(self) -> None:
self.assertEqual(parse_export_sections("summary"), frozenset({"summary"}))
def test_filename_all(self) -> None:
name = export_filename_base(
date_from="2026-06-01",
date_to="2026-06-07",
sections=frozenset({"summary", "comparison", "reports"}),
)
self.assertEqual(name, "otchety_2026-06-01_2026-06-07")
def test_filename_summary(self) -> None:
name = export_filename_base(
date_from="2026-06-01",
date_to="2026-06-07",
sections=frozenset({"summary"}),
)
self.assertEqual(name, "itogi_2026-06-01_2026-06-07")
def test_net_rub_display_without_minus(self) -> None:
self.assertEqual(net_rub_display({"netRub": -23480.99}), 23480.99)
self.assertEqual(net_rub_display({"netRub": 1500.0}), 1500.0)
def test_net_rub_dominant_hint(self) -> None:
self.assertIn("надоев", net_rub_dominant_hint({"dominantIssue": "underload"}))
self.assertIn("перерасход", net_rub_dominant_hint({"dominantIssue": "overload"}))
def test_pdf_rub_format_without_sign_or_ruble_symbol(self) -> None:
self.assertEqual(_format_rub(-23480.99), "23 481 руб.")
self.assertNotIn("", _format_rub(1500))
self.assertEqual(_format_rub_plain(-10154), "10 154")
def test_pdf_table_widths_fit_landscape_page(self) -> None:
comparison_total = sum(_COMPARISON_COL_WIDTHS)
detail_total = sum(_DETAIL_COL_WIDTHS)
self.assertLessEqual(comparison_total, _USABLE_WIDTH + 0.1)
self.assertLessEqual(detail_total, _USABLE_WIDTH + 0.1)
self.assertEqual(sum(_DETAIL_COL_PCT), 100)
self.assertAlmostEqual(detail_total, _USABLE_WIDTH, places=1)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,26 @@
"""parse_recipe_ids для analytics."""
from __future__ import annotations
import unittest
from app.services.analytics.recipe_filter import parse_recipe_ids
class RecipeFilterTests(unittest.TestCase):
def test_single_recipe_id(self) -> None:
self.assertEqual(parse_recipe_ids(recipe_id="abc"), ["abc"])
def test_multiple_recipe_ids(self) -> None:
self.assertEqual(parse_recipe_ids(recipe_ids="a,b, c"), ["a", "b", "c"])
def test_recipe_id_takes_priority(self) -> None:
self.assertEqual(parse_recipe_ids(recipe_id="one", recipe_ids="two,three"), ["one"])
def test_empty_returns_none(self) -> None:
self.assertIsNone(parse_recipe_ids())
self.assertIsNone(parse_recipe_ids(recipe_ids=" , "))
if __name__ == "__main__":
unittest.main()
+576
View File
@@ -0,0 +1,576 @@
"""Unit-тесты analytics services."""
from __future__ import annotations
import unittest
from datetime import datetime
from app import create_app, db
from app.models import (
Component,
LoadingReport,
LoadingReportComponent,
Recipe,
UnloadingReport,
UnloadingReportGroup,
)
from app.services.analytics.finance_summary import build_finance_summary
from app.services.analytics.plan_fact_layers import build_plan_fact_rows
from app.services.daily_plan.skips import skip_ingredient
from app.services.setup_state import mark_setup_complete
from tests.helpers.dispenser_recipe_fixtures import (
E2E_DISP_COMP,
E2E_DISP_RECIPE_1,
seed_dispenser_period_recipes,
)
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class AnalyticsFinanceSummaryTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
db.session.add(
Component(
id="an-comp-1",
name="Премикс",
type="premix",
dry_matter=90.0,
protein=0.0,
energy=0.0,
price=150.0,
)
)
db.session.add(
Recipe(id="an-r1", name="Рейс A", heads_per_trip=10, content_hash="")
)
report = LoadingReport(
id="an-lr-1",
recipe_id="an-r1",
recipe_name="Рейс A",
start_time=datetime(2026, 6, 7, 10, 0, 0),
target_mixing_time=60,
actual_mixing_time=60,
total_weight=100.0,
created_by="system",
updated_by="system",
)
db.session.add(report)
db.session.flush()
db.session.add(
LoadingReportComponent(
report_id=report.id,
component_id="an-comp-1",
component_name="Премикс",
target_weight=100.0,
actual_weight=110.0,
overload=10.0,
loading_order=1,
created_by="system",
updated_by="system",
)
)
db.session.add(
LoadingReportComponent(
report_id=report.id,
component_id="an-comp-1",
component_name="Премикс",
target_weight=50.0,
actual_weight=40.0,
overload=0.0,
loading_order=2,
created_by="system",
updated_by="system",
)
)
db.session.commit()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_overload_and_underload_rub(self) -> None:
summary = build_finance_summary(date_from="2026-06-07", date_to="2026-06-07")
# +10 kg * 150 = 1500 overload; -10 kg * 150 = 1500 underload
self.assertEqual(summary["overloadRub"], 1500.0)
self.assertEqual(summary["underloadRub"], 1500.0)
self.assertEqual(summary["netRub"], 0.0)
self.assertEqual(summary["dominantIssue"], "balanced")
self.assertEqual(len(summary["topComponents"]), 1)
self.assertEqual(summary["topComponents"][0]["name"], "Премикс")
class AnalyticsPlanFactTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
seed_dispenser_period_recipes()
report = LoadingReport(
id="pf-lr-1",
recipe_id=E2E_DISP_RECIPE_1,
recipe_name="E2E Рейс 1",
start_time=datetime(2026, 6, 7, 8, 0, 0),
target_mixing_time=60,
actual_mixing_time=60,
total_weight=25.0,
created_by="system",
updated_by="system",
)
db.session.add(report)
db.session.flush()
db.session.add(
LoadingReportComponent(
report_id=report.id,
component_id=E2E_DISP_COMP,
component_name="E2E Disp компонент",
target_weight=20.0,
actual_weight=45.0,
overload=25.0,
loading_order=1,
created_by="system",
updated_by="system",
)
)
db.session.commit()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_plan_fact_layers_and_notes(self) -> None:
data = build_plan_fact_rows(date_from="2026-06-07", date_to="2026-06-07")
self.assertEqual(data["reportCount"], 1)
item = data["items"][0]
self.assertEqual(item["recipeId"], E2E_DISP_RECIPE_1)
comps = item["components"]
self.assertEqual(len(comps), 1)
c = comps[0]
self.assertEqual(c["baseKg"], 20.0)
self.assertEqual(c["planTodayKg"], 20.0)
self.assertEqual(c["actualKg"], 45.0)
exec_notes = [n for n in c["notes"] if n.get("kind") == "execution"]
self.assertEqual(len(exec_notes), 1)
self.assertIn("перегруз", exec_notes[0]["text"])
self.assertEqual(c["fault"], "execution")
def test_zootech_skip_without_report_row(self) -> None:
"""Исключённый ингредиент без строки в отчёте терминала — всё равно в сравнении."""
skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", "2026-06-07")
report = LoadingReport(
id="pf-lr-skip-only-other",
recipe_id=E2E_DISP_RECIPE_1,
recipe_name="E2E Рейс 1",
start_time=datetime(2026, 6, 7, 11, 0, 0),
target_mixing_time=60,
actual_mixing_time=60,
total_weight=100.0,
created_by="system",
updated_by="system",
)
db.session.add(report)
db.session.flush()
db.session.add(
LoadingReportComponent(
report_id=report.id,
component_id="e2e-disp-comp-other",
component_name="Другой компонент",
target_weight=100.0,
actual_weight=100.0,
overload=0.0,
loading_order=2,
created_by="system",
updated_by="system",
)
)
db.session.commit()
data = build_plan_fact_rows(date_from="2026-06-07", date_to="2026-06-07")
item = next(
(i for i in data["items"] if i["loadingReportId"] == "pf-lr-skip-only-other"),
None,
)
self.assertIsNotNone(item)
skipped_rows = [c for c in item["components"] if c.get("skippedToday")]
self.assertEqual(len(skipped_rows), 1)
self.assertEqual(skipped_rows[0]["fault"], "excluded")
self.assertEqual(skipped_rows[0]["actualKg"], 0.0)
self.assertGreater(skipped_rows[0]["baseKg"], 0.0)
def test_zootech_skip_no_execution_fault(self) -> None:
skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", "2026-06-07")
report = LoadingReport(
id="pf-lr-skip",
recipe_id=E2E_DISP_RECIPE_1,
recipe_name="E2E Рейс 1",
start_time=datetime(2026, 6, 7, 9, 0, 0),
target_mixing_time=60,
actual_mixing_time=60,
total_weight=0.0,
created_by="system",
updated_by="system",
)
db.session.add(report)
db.session.flush()
db.session.add(
LoadingReportComponent(
report_id=report.id,
component_id=E2E_DISP_COMP,
component_name="E2E Disp компонент",
target_weight=0.0,
actual_weight=0.0,
overload=0.0,
loading_order=1,
created_by="system",
updated_by="system",
)
)
db.session.commit()
data = build_plan_fact_rows(date_from="2026-06-07", date_to="2026-06-07")
skip_item = next(
(i for i in data["items"] if i["loadingReportId"] == "pf-lr-skip"),
None,
)
self.assertIsNotNone(skip_item)
c = skip_item["components"][0]
self.assertEqual(c["planTodayKg"], 0.0)
self.assertEqual(c["actualKg"], 0.0)
self.assertEqual(c["fault"], "excluded")
self.assertTrue(c["skippedToday"])
zootech_notes = [n for n in c["notes"] if n.get("kind") == "zootech"]
self.assertEqual(len(zootech_notes), 1)
self.assertIn("исключено из плана", zootech_notes[0]["text"])
self.assertNotIn("execution", [n.get("kind") for n in c["notes"]])
def test_operator_underload_is_execution_not_excluded(self) -> None:
"""Полный недогруз при плане 20 кг — в пределах допуска ±20 кг, без ошибки."""
report = LoadingReport(
id="pf-lr-hay",
recipe_id=E2E_DISP_RECIPE_1,
recipe_name="E2E Рейс 1",
start_time=datetime(2026, 6, 7, 10, 0, 0),
target_mixing_time=60,
actual_mixing_time=60,
total_weight=0.0,
created_by="system",
updated_by="system",
)
db.session.add(report)
db.session.flush()
db.session.add(
LoadingReportComponent(
report_id=report.id,
component_id=E2E_DISP_COMP,
component_name="E2E Disp компонент",
target_weight=20.0,
actual_weight=0.0,
overload=0.0,
loading_order=1,
created_by="system",
updated_by="system",
)
)
db.session.commit()
data = build_plan_fact_rows(date_from="2026-06-07", date_to="2026-06-07")
hay_item = next(
(i for i in data["items"] if i["loadingReportId"] == "pf-lr-hay"),
None,
)
self.assertIsNotNone(hay_item)
c = hay_item["components"][0]
self.assertEqual(c["planTodayKg"], 20.0)
self.assertEqual(c["actualKg"], 0.0)
self.assertFalse(c["skippedToday"])
self.assertEqual(c["fault"], "none")
self.assertEqual([n for n in c["notes"] if n.get("kind") == "execution"], [])
def test_plan_fact_unloading_and_mixer_remainder(self) -> None:
unloading = UnloadingReport(
id="pf-ur-1",
recipe_id=E2E_DISP_RECIPE_1,
recipe_name="E2E Рейс 1",
loading_report_id="pf-lr-1",
start_time=datetime(2026, 6, 7, 8, 30, 0),
end_time=datetime(2026, 6, 7, 8, 45, 0),
total_weight=25.0,
total_unloaded_weight=22.0,
remaining_weight=3.0,
created_by="system",
updated_by="system",
)
db.session.add(unloading)
db.session.flush()
db.session.add(
UnloadingReportGroup(
report_id=unloading.id,
name="Г1",
target_weight=20.0,
unloaded_weight=45.0,
remaining_weight=0.0,
distribution_type="percent",
distribution_value=100.0,
order=1,
created_by="system",
updated_by="system",
)
)
db.session.commit()
data = build_plan_fact_rows(date_from="2026-06-07", date_to="2026-06-07")
item = data["items"][0]
self.assertEqual(len(item["unloadingGroups"]), 1)
grp = item["unloadingGroups"][0]
self.assertEqual(grp["name"], "Г1")
self.assertEqual(grp["actualKg"], 45.0)
self.assertEqual(grp["fault"], "execution")
self.assertIsNotNone(item["mixerRemainder"])
self.assertEqual(item["mixerRemainder"]["actualKg"], 3.0)
self.assertEqual(item["mixerRemainder"]["fault"], "none")
def test_plan_fact_mixer_remainder_can_be_negative(self) -> None:
unloading = UnloadingReport(
id="pf-ur-neg",
recipe_id=E2E_DISP_RECIPE_1,
recipe_name="E2E Рейс 1",
loading_report_id="pf-lr-1",
start_time=datetime(2026, 6, 7, 9, 0, 0),
end_time=datetime(2026, 6, 7, 9, 15, 0),
total_weight=25.0,
total_unloaded_weight=30.0,
remaining_weight=-5.0,
created_by="system",
updated_by="system",
)
db.session.add(unloading)
db.session.commit()
data = build_plan_fact_rows(date_from="2026-06-07", date_to="2026-06-07")
item = next(i for i in data["items"] if i["loadingReportId"] == "pf-lr-1")
self.assertIsNotNone(item["mixerRemainder"])
self.assertEqual(item["mixerRemainder"]["actualKg"], -5.0)
self.assertEqual(item["mixerRemainder"]["fault"], "none")
class AnalyticsReportsDetailTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
db.session.add(
Recipe(id="an-r-detail", name="Рейс detail", heads_per_trip=10, content_hash="")
)
report = LoadingReport(
id="an-lr-detail",
recipe_id="an-r-detail",
recipe_name="Рейс detail",
start_time=datetime(2026, 6, 7, 17, 43, 0),
target_mixing_time=60,
actual_mixing_time=60,
total_weight=100.0,
created_by="system",
updated_by="system",
)
db.session.add(report)
db.session.add(
LoadingReportComponent(
report_id=report.id,
component_name="БУРТ",
target_weight=394.1,
actual_weight=0.0,
overload=-394.1,
loading_order=1,
created_by="system",
updated_by="system",
)
)
db.session.add(
LoadingReportComponent(
report_id=report.id,
component_name="БУРТ",
target_weight=394.1,
actual_weight=0.0,
overload=-394.1,
loading_order=2,
created_by="system",
updated_by="system",
)
)
db.session.add(
LoadingReportComponent(
report_id=report.id,
component_name="Кукуруза",
target_weight=400.0,
actual_weight=450.0,
overload=50.0,
loading_order=3,
created_by="system",
updated_by="system",
)
)
db.session.commit()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_overload_only_when_fact_exceeds_plan(self) -> None:
from app.services.analytics.reports_detail import build_reports_detail_rows
rows = build_reports_detail_rows(date_from="2026-06-07", date_to="2026-06-07")
by_name = {r["feedComponent"]: r for r in rows if r["kind"] == "component"}
self.assertEqual(by_name["БУРТ"]["deviationKg"], -394.1)
self.assertEqual(by_name["БУРТ"]["overloadKg"], 0.0)
self.assertEqual(by_name["Кукуруза"]["deviationKg"], 50.0)
self.assertEqual(by_name["Кукуруза"]["overloadKg"], 50.0)
def test_dedupe_identical_component_rows(self) -> None:
from app.services.analytics.reports_detail import build_reports_detail_rows
rows = build_reports_detail_rows(date_from="2026-06-07", date_to="2026-06-07")
burt_rows = [r for r in rows if r.get("feedComponent") == "БУРТ"]
self.assertEqual(len(burt_rows), 1)
class AnalyticsReportsDetailUnloadingTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
from app.models import UnloadingReport, UnloadingReportGroup
db.session.add(
Recipe(id="an-r-unload", name="Рейс unload", heads_per_trip=10, content_hash="")
)
report = LoadingReport(
id="an-lr-unload",
recipe_id="an-r-unload",
recipe_name="Рейс unload",
start_time=datetime(2026, 6, 7, 18, 0, 0),
target_mixing_time=300,
actual_mixing_time=300,
total_weight=3340.0,
created_by="system",
updated_by="system",
)
db.session.add(report)
db.session.flush()
unloading = UnloadingReport(
id="an-ur-1",
recipe_id="an-r-unload",
recipe_name="Рейс unload",
loading_report_id=report.id,
start_time=datetime(2026, 6, 7, 18, 30, 0),
total_weight=3340.0,
total_unloaded_weight=3300.0,
remaining_weight=40.0,
created_by="system",
updated_by="system",
)
db.session.add(unloading)
db.session.flush()
db.session.add(
UnloadingReportGroup(
report_id=unloading.id,
name="выгрузка: 4 гр 1 зам",
target_weight=3340.0,
unloaded_weight=3300.0,
remaining_weight=40.0,
distribution_type="percent",
distribution_value=100.0,
order=1,
created_by="system",
updated_by="system",
)
)
db.session.add(
UnloadingReportGroup(
report_id=unloading.id,
name="все",
target_weight=1710.0,
unloaded_weight=1710.0,
remaining_weight=0.0,
distribution_type="percent",
distribution_value=100.0,
order=2,
created_by="system",
updated_by="system",
)
)
db.session.commit()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_unloading_row_schema(self) -> None:
from app.services.analytics.reports_detail import (
UNLOADING_FEED_COMPONENT_LABEL,
build_reports_detail_rows,
)
rows = [r for r in build_reports_detail_rows(date_from="2026-06-07", date_to="2026-06-07") if r["kind"] == "unloading"]
self.assertEqual(len(rows), 2)
by_group = {r["animalGroup"]: r for r in rows}
self.assertEqual(by_group["4 гр 1 зам"]["feedComponent"], UNLOADING_FEED_COMPONENT_LABEL)
self.assertEqual(by_group["4 гр 1 зам"]["targetKg"], 3340.0)
self.assertEqual(by_group["4 гр 1 зам"]["actualKg"], 3300.0)
self.assertEqual(by_group["4 гр 1 зам"]["deviationKg"], -40.0)
self.assertEqual(by_group["все"]["feedComponent"], UNLOADING_FEED_COMPONENT_LABEL)
self.assertEqual(by_group["все"]["targetKg"], 1710.0)
self.assertEqual(by_group["все"]["actualKg"], 1710.0)
self.assertEqual(by_group["все"]["deviationKg"], 0.0)
def test_detail_xlsx_unloading_column_alignment(self) -> None:
from io import BytesIO
from openpyxl import load_workbook
from app.services.analytics.reports_detail import UNLOADING_FEED_COMPONENT_LABEL
from app.services.analytics.xlsx_summary import build_finance_summary_xlsx
xlsx = build_finance_summary_xlsx(
date_from="2026-06-07",
date_to="2026-06-07",
section="reports",
)
ws = load_workbook(BytesIO(xlsx))["Подробно"]
self.assertEqual(ws.cell(1, 8).value, "Группа")
self.assertGreaterEqual(ws.column_dimensions["B"].width, 14)
self.assertGreaterEqual(ws.column_dimensions["I"].width, 8)
farm_cell = ws.cell(row=2, column=2)
self.assertTrue(farm_cell.alignment.wrap_text)
unloading_rows = [
row
for row in ws.iter_rows(min_row=2, values_only=True)
if row[5] == "Выгрузка"
]
self.assertEqual(len(unloading_rows), 2)
for row in unloading_rows:
self.assertEqual(row[6], UNLOADING_FEED_COMPONENT_LABEL)
self.assertIsInstance(row[8], (int, float))
self.assertIsInstance(row[9], (int, float))
self.assertEqual(round(row[10], 2), round(row[9] - row[8], 2))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,65 @@
"""Unit-тесты stock forecast."""
from __future__ import annotations
import unittest
from app import create_app, db
from app.models import ComponentStock
from app.services.analytics.stock_forecast import build_stock_forecast
from app.services.daily_plan.skips import skip_ingredient
from app.services.setup_state import mark_setup_complete
from app.timeutil import utc_now_naive
from tests.helpers.dispenser_recipe_fixtures import (
E2E_DISP_COMP,
E2E_DISP_RECIPE_1,
seed_dispenser_period_recipes,
)
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class AnalyticsStockForecastTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
seed_dispenser_period_recipes()
db.session.add(
ComponentStock(
component_id=E2E_DISP_COMP,
component_name="E2E Disp компонент",
total_kg=100.0,
baseline_consumed_kg=0.0,
stocktake_at=utc_now_naive(),
updated_at=utc_now_naive(),
updated_by="system",
)
)
db.session.commit()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_forecast_includes_adjusted_days(self) -> None:
data = build_stock_forecast(plan_date="2026-06-07")
self.assertIn("items", data)
item = next((i for i in data["items"] if i["component_id"] == E2E_DISP_COMP), None)
self.assertIsNotNone(item)
self.assertIn("daysLeftAdjusted", item)
self.assertIn("daysLeftAdjustedLabel", item)
def test_skip_changes_plan_today(self) -> None:
before = build_stock_forecast(plan_date="2026-06-07")
item_before = next(i for i in before["items"] if i["component_id"] == E2E_DISP_COMP)
skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", "2026-06-07")
after = build_stock_forecast(plan_date="2026-06-07")
item_after = next(i for i in after["items"] if i["component_id"] == E2E_DISP_COMP)
self.assertLess(item_after["planTodayKgPerDay"], item_before["planTodayKgPerDay"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,36 @@
"""UI-контракт analytics на /reports и /feed_consumption."""
from __future__ import annotations
import unittest
from tests.helpers.zootech_test_helpers import STATIC
class AnalyticsUiContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.reports_html = (STATIC / "reports.html").read_text(encoding="utf-8")
cls.consumption_html = (STATIC / "consumption.html").read_text(encoding="utf-8")
cls.copy_js = (STATIC / "js" / "pages" / "analytics-copy.js").read_text(encoding="utf-8")
def test_reports_has_analytics_tabs(self) -> None:
self.assertIn('data-reports-view-tab="summary"', self.reports_html)
self.assertIn('data-reports-view-tab="comparison"', self.reports_html)
self.assertIn('data-reports-view-tab="journal"', self.reports_html)
self.assertIn('data-journal-view-tab="alerts"', self.reports_html)
self.assertIn("analytics-summary.js", self.reports_html)
self.assertIn("analytics-plan-fact.js", self.reports_html)
def test_consumption_has_stock_forecast(self) -> None:
self.assertIn("stockForecastBanner", self.consumption_html)
self.assertIn("stock-forecast.js", self.consumption_html)
self.assertIn("data-stock-days-adjusted", self.consumption_html)
def test_copy_uses_zootech_labels(self) -> None:
self.assertIn("Перерасход", self.copy_js)
self.assertIn("Недогруз", self.copy_js)
self.assertIn("По рецепту", self.copy_js)
if __name__ == "__main__":
unittest.main()
+29
View File
@@ -0,0 +1,29 @@
"""Куда вести после старта и setup — /scales только на ARM."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from app.services.hardware_settings_service import (
default_app_landing_path,
setup_complete_redirect,
)
class AppLandingPathTests(unittest.TestCase):
@patch("app.services.hardware_settings_service.is_scale_hardware_platform", return_value=False)
def test_x86_defaults_to_login(self, _mock: object) -> None:
self.assertEqual(default_app_landing_path(), "/login")
self.assertEqual(setup_complete_redirect("client"), "/login")
self.assertEqual(setup_complete_redirect("server"), "/login")
@patch("app.services.hardware_settings_service.is_scale_hardware_platform", return_value=True)
def test_arm_client_goes_to_scales(self, _mock: object) -> None:
self.assertEqual(default_app_landing_path(), "/scales")
self.assertEqual(setup_complete_redirect("client"), "/scales")
self.assertEqual(setup_complete_redirect("server"), "/login")
if __name__ == "__main__":
unittest.main()
+96
View File
@@ -0,0 +1,96 @@
import os
import tempfile
import unittest
from app import create_app, db
from app.models import WebUser
from config import TestingConfig
from werkzeug.security import check_password_hash, generate_password_hash
class AuthEnvTestConfig(TestingConfig):
_TMP_DIR = tempfile.mkdtemp(prefix="wesp-auth-tests-")
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(_TMP_DIR, 'recipes_test.db')}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(_TMP_DIR, 'reports_test.db')}"}
AUTH_LOGIN = "env-admin"
AUTH_PASSWORD = "env-secret"
class AuthEnvOnlyTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(AuthEnvTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
db.session.add(
WebUser(
login="env-admin",
password_hash=generate_password_hash("env-secret"),
is_superuser=True,
)
)
db.session.commit()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_login_uses_env_credentials(self) -> None:
bad = self.client.post(
"/api/auth/login", json={"login": "env-admin", "password": "wrong"}
)
self.assertEqual(bad.status_code, 401)
ok = self.client.post(
"/api/auth/login", json={"login": "env-admin", "password": "env-secret"}
)
self.assertEqual(ok.status_code, 200)
self.assertTrue(ok.get_json().get("authenticated"))
def test_get_current_credentials_never_returns_password(self) -> None:
self.client.post(
"/api/auth/login", json={"login": "env-admin", "password": "env-secret"}
)
resp = self.client.get("/api/auth/get_current_credentials")
self.assertEqual(resp.status_code, 200)
body = resp.get_json()
self.assertEqual(body.get("login"), "env-admin")
self.assertEqual(body.get("password"), "")
self.assertEqual(body.get("user_rules", {}).get("password_min_len"), 8)
def test_change_credentials_updates_web_user_with_policy(self) -> None:
self.client.post(
"/api/auth/login", json={"login": "env-admin", "password": "env-secret"}
)
resp = self.client.post(
"/api/auth/change_credentials",
json={
"old_login": "env-admin",
"old_password": "env-secret",
"new_login": "env-admin-2",
"new_password": "new-secret-9",
"confirm_password": "new-secret-9",
},
)
self.assertEqual(resp.status_code, 200, resp.get_data(as_text=True))
user = db.session.query(WebUser).filter_by(login="env-admin-2").one()
self.assertTrue(check_password_hash(user.password_hash, "new-secret-9"))
weak = self.client.post(
"/api/auth/change_credentials",
json={
"old_login": "env-admin-2",
"old_password": "new-secret-9",
"new_login": "env-admin-2",
"new_password": "12345678",
"confirm_password": "12345678",
},
)
self.assertEqual(weak.status_code, 400, weak.get_data(as_text=True))
if __name__ == "__main__":
unittest.main()
+72
View File
@@ -0,0 +1,72 @@
import os
import sqlite3
import tempfile
import unittest
from app.services.auto_update_db import load_auto_update_dict, merge_auto_update_settings
class AutoUpdateDbTests(unittest.TestCase):
def setUp(self) -> None:
self._root = tempfile.mkdtemp(prefix="wesp-auto-update-db-")
data = os.path.join(self._root, "data")
os.makedirs(data, exist_ok=True)
self._db = os.path.join(data, "recipes.db")
conn = sqlite3.connect(self._db)
conn.execute(
"""
CREATE TABLE auto_update_settings (
id INTEGER NOT NULL PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
auto_install INTEGER NOT NULL DEFAULT 0,
gitea_url VARCHAR(512) NOT NULL DEFAULT '',
gitea_owner VARCHAR(255) NOT NULL DEFAULT '',
gitea_repo VARCHAR(255) NOT NULL DEFAULT '',
repository_url VARCHAR(512) NOT NULL DEFAULT '',
check_interval_sec INTEGER NOT NULL DEFAULT 3600,
CHECK (id = 1)
)
"""
)
conn.commit()
conn.close()
def test_merge_inserts_and_updates_partially(self) -> None:
merge_auto_update_settings(
self._root,
{
"enabled": True,
"gitea_owner": "farm",
"gitea_repo": "wesp",
"check_interval_sec": 120,
},
)
au = load_auto_update_dict(self._root)
self.assertIsNotNone(au)
assert au is not None
self.assertTrue(au["enabled"])
self.assertFalse(au["auto_install"])
self.assertEqual(au["gitea_owner"], "farm")
self.assertEqual(au["gitea_repo"], "wesp")
self.assertEqual(au["check_interval_sec"], 120)
merge_auto_update_settings(
self._root,
{"auto_install": True, "gitea_url": "https://git.example.com"},
)
au2 = load_auto_update_dict(self._root)
self.assertIsNotNone(au2)
assert au2 is not None
self.assertTrue(au2["enabled"])
self.assertTrue(au2["auto_install"])
self.assertEqual(au2["gitea_url"], "https://git.example.com")
self.assertEqual(au2["gitea_owner"], "farm")
def test_merge_missing_db_raises(self) -> None:
empty_root = tempfile.mkdtemp(prefix="wesp-no-db-")
with self.assertRaises(FileNotFoundError):
merge_auto_update_settings(empty_root, {"enabled": True})
if __name__ == "__main__":
unittest.main()
+136
View File
@@ -0,0 +1,136 @@
"""Тесты фоновой проверки обновлений (без автоустановки)."""
from __future__ import annotations
import json
import os
import sqlite3
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from app.services.auto_update_db import upsert_auto_update_dict
from update import AutoUpdater
class AutoUpdateRuntimeTests(unittest.TestCase):
def setUp(self) -> None:
self._root = tempfile.mkdtemp(prefix="wesp-update-runtime-")
data = os.path.join(self._root, "data")
os.makedirs(data, exist_ok=True)
self._db = os.path.join(data, "recipes.db")
conn = sqlite3.connect(self._db)
conn.execute(
"""
CREATE TABLE auto_update_settings (
id INTEGER NOT NULL PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
auto_install INTEGER NOT NULL DEFAULT 0,
gitea_url VARCHAR(512) NOT NULL DEFAULT '',
gitea_owner VARCHAR(255) NOT NULL DEFAULT '',
gitea_repo VARCHAR(255) NOT NULL DEFAULT '',
repository_url VARCHAR(512) NOT NULL DEFAULT '',
check_interval_sec INTEGER NOT NULL DEFAULT 3600,
CHECK (id = 1)
)
"""
)
conn.commit()
conn.close()
upsert_auto_update_dict(
self._root,
{
"enabled": True,
"gitea_url": "https://git.example.com",
"gitea_owner": "farm",
"gitea_repo": "wesp",
"check_interval_sec": 120,
},
)
cfg_path = os.path.join(data, "config.json")
with open(cfg_path, "w", encoding="utf-8") as f:
json.dump({"version": "1.0.0"}, f)
@patch.dict(os.environ, {"GITEA_TOKEN": "test-token"}, clear=False)
@patch("update.requests.get")
def test_check_caches_pending_release(self, mock_get: MagicMock) -> None:
mock_get.return_value = MagicMock(
status_code=200,
json=lambda: [
{
"tag_name": "v2.0.0",
"name": "Release 2",
"body": "Changelog",
"published_at": "2026-01-01T00:00:00Z",
"assets": [],
}
],
)
with patch.object(AutoUpdater, "__init__", lambda self: None):
updater = AutoUpdater()
updater.base_dir = self._root
updater.config_file = os.path.join(self._root, "data", "config.json")
updater.enabled = True
updater.gitea_url = "https://git.example.com"
updater.gitea_owner = "farm"
updater.gitea_repo = "wesp"
updater.gitea_token = "test-token"
updater.gitea_username = ""
updater.gitea_password = ""
updater.current_version = "1.0.0"
updater.pending_release = None
updater.last_check_at = None
updater._state_lock = __import__("threading").Lock()
updater._last_forced_check_mono = 0.0
updater.protected_files = []
updater.protected_folders = []
info = updater.check_for_updates()
self.assertIsNotNone(info)
assert info is not None
self.assertEqual(info["version"], "2.0.0")
pending = updater.get_pending_update()
self.assertIsNotNone(pending)
assert pending is not None
self.assertEqual(pending["version"], "2.0.0")
@patch.dict(os.environ, {"GITEA_TOKEN": "test-token"}, clear=False)
@patch("update.requests.get")
def test_update_loop_does_not_auto_install(self, mock_get: MagicMock) -> None:
mock_get.return_value = MagicMock(
status_code=200,
json=lambda: [
{
"tag_name": "v9.9.9",
"name": "Never auto",
"body": "",
"published_at": "",
"assets": [{"name": "wesp.zip", "browser_download_url": "http://x/wesp.zip"}],
}
],
)
with patch.object(AutoUpdater, "__init__", lambda self: None):
updater = AutoUpdater()
updater.base_dir = self._root
updater.enabled = True
updater.is_running = True
updater.check_interval = 1
updater.gitea_url = "https://git.example.com"
updater.gitea_owner = "farm"
updater.gitea_repo = "wesp"
updater.gitea_token = "t"
updater.current_version = "1.0.0"
updater.pending_release = None
updater.last_check_at = None
updater._state_lock = __import__("threading").Lock()
updater._last_forced_check_mono = 0.0
updater.protected_files = []
updater.protected_folders = []
with patch.object(updater, "update") as mock_update:
updater.check_for_updates()
mock_update.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,90 @@
"""cleanup_legacy_profiles command."""
from __future__ import annotations
import unittest
from app import create_app, db
from app.lab.commands.cleanup_legacy_profiles import cleanup_legacy_profiles
from app.lab.models import LabAnimalProfile, LabRecipeRation
from app.models.base import default_uuid
from app.services.setup_state import mark_setup_complete
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class CleanupLegacyProfilesTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_dry_run_lists_legacy_and_fp(self) -> None:
legacy = LabAnimalProfile(
id=default_uuid(),
profile_key="dairy-1",
label="Legacy",
ration_type="DAIRY",
created_by="test",
updated_by="test",
)
fp = LabAnimalProfile(
id=default_uuid(),
profile_key="fp_dairy_0001",
label="FP",
ration_type="DAIRY",
created_by="test",
updated_by="test",
)
math = LabAnimalProfile(
id=default_uuid(),
profile_key="lab_math_dairy_01",
label="Math",
ration_type="DAIRY",
created_by="test",
updated_by="test",
)
from app.models import Recipe
recipe = Recipe(id=default_uuid(), name="T", heads_per_trip=1, mixing_time=1)
db.session.add(recipe)
db.session.add_all([legacy, fp, math])
db.session.flush()
db.session.add(
LabRecipeRation(
recipe_id=recipe.id,
animal_profile_id=legacy.id,
created_by="test",
updated_by="test",
)
)
db.session.commit()
report = cleanup_legacy_profiles(dry_run=True)
self.assertIn("dairy-1", report.profiles_to_delete)
self.assertIn("fp_dairy_0001", report.profiles_to_delete)
self.assertNotIn("lab_math_dairy_01", report.profiles_to_delete)
self.assertEqual(report.profiles_deleted, 0)
def test_apply_deletes_legacy(self) -> None:
legacy = LabAnimalProfile(
id=default_uuid(),
profile_key="beef-2",
label="Legacy",
ration_type="BEEF",
created_by="test",
updated_by="test",
)
db.session.add(legacy)
db.session.commit()
report = cleanup_legacy_profiles(dry_run=False)
self.assertEqual(report.profiles_deleted, 1)
remaining = LabAnimalProfile.query.filter_by(is_deleted=False).count()
self.assertEqual(remaining, 0)
@@ -0,0 +1,70 @@
"""save_component_nutrients — единая точка записи."""
from __future__ import annotations
import unittest
from app import create_app, db
from app.lab.models import LabComponentNutrientValue
from app.lab.services.component_nutrients import nutrients_api_dict, save_component_nutrients
from app.models import Component
from app.models.base import default_uuid
from app.services.setup_state import mark_setup_complete
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class ComponentNutrientsSaveTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
self.comp = Component(
id=default_uuid(),
name="Save test",
dry_matter=88.0,
created_by="test",
updated_by="test",
)
db.session.add(self.comp)
db.session.commit()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_save_returns_affected_recipes(self) -> None:
result = save_component_nutrients(
self.comp.id,
{"Сыр. Протеин": 110, "Сырая клетч": 300, "Сырой жир": 20},
)
self.assertIn("stored", result)
self.assertIn("affectedRecipeIds", result)
self.assertEqual(nutrients_api_dict(self.comp.id).get("Сыр. Протеин"), 110)
def test_save_preserves_extra_lab_keys_after_derive(self) -> None:
result = save_component_nutrients(
self.comp.id,
{
"Сыр. Протеин": 107.5,
"Сырая клетч": 645.5,
"Сырой жир": 44.5,
"Лизин": 4.4,
"Ca": 7.3,
"ВРХ Орг Вещ": 53.0,
},
)
stored = nutrients_api_dict(self.comp.id)
self.assertEqual(stored.get("Лизин"), 4.4)
self.assertEqual(stored.get("Ca"), 7.3)
self.assertIn("stored", result)
def test_sv_not_stored_in_eav(self) -> None:
save_component_nutrients(
self.comp.id,
{"Сыр. Протеин": 100, "Сырая клетч": 280, "Сырой жир": 18, "СВ": 999},
)
keys = {r.nutrient_key for r in LabComponentNutrientValue.query.filter_by(component_id=self.comp.id)}
self.assertNotIn("СВ", keys)
+55
View File
@@ -0,0 +1,55 @@
"""Components API: nutrients без обязательных protein/energy."""
from __future__ import annotations
import json
import unittest
from app import create_app, db
from app.lab.services.component_nutrients import nutrients_api_dict
from app.models import Component
from app.services.setup_state import mark_setup_complete
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class ComponentRefactorTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
self.client.post(
"/api/auth/login",
json={"login": "zootech-test-admin", "password": "zootech-test-secret"},
)
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_create_component_with_nutrients(self) -> None:
r = self.client.post(
"/api/components",
json={
"name": "Тест lab nutrients",
"type": "Концентрированные",
"dry_matter": 88.0,
"price": 12.0,
"protein": 0,
"energy": 0,
"nutrients": {"Сыр. Протеин": 110, "СВ": 880},
},
)
self.assertEqual(r.status_code, 201, r.get_data(as_text=True))
comp_id = r.get_json()["id"]
nutrients = nutrients_api_dict(comp_id)
self.assertEqual(nutrients.get("Сыр. Протеин"), 110)
def test_get_component_returns_nutrients(self) -> None:
r = self.client.get("/api/components")
self.assertEqual(r.status_code, 200)
body = r.get_json()
self.assertTrue(isinstance(body, list))
+149
View File
@@ -0,0 +1,149 @@
"""API /api/components — CRUD."""
from __future__ import annotations
import unittest
import uuid
from app import create_app, db
from app.models import Component
from app.services.setup_state import mark_setup_complete
from tests.helpers.zootech_test_helpers import ZootechTestConfig, drain_response
class ComponentsApiTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
self.client.post(
"/api/auth/login",
json={"login": "zootech-test-admin", "password": "zootech-test-secret"},
)
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_feed_types_endpoint(self) -> None:
r = self.client.get("/api/components/feed-types")
self.assertEqual(r.status_code, 200)
types = r.get_json()["types"]
values = {t["value"] for t in types}
self.assertIn("Грубые корма", values)
self.assertIn("Сочные корма", values)
def test_reject_invalid_feed_type(self) -> None:
r = self.client.post(
"/api/components",
json={"name": "Тест", "type": "Зерновые", "dryMatter": 88.0},
)
self.assertEqual(r.status_code, 400)
def test_components_crud(self) -> None:
create = self.client.post(
"/api/components",
json={
"name": "Соя",
"type": "Концентрированные",
"dryMatter": 88.0,
"price": 0,
},
)
self.assertEqual(create.status_code, 201, create.get_data(as_text=True))
cid = create.get_json()["id"]
one = self.client.get(f"/api/components/{cid}")
self.assertEqual(one.get_json()["externalNo"], 1)
second = self.client.post(
"/api/components",
json={
"name": "Сено",
"type": "Грубые корма",
"dryMatter": 88.0,
"price": 0,
},
)
self.assertEqual(second.status_code, 201)
self.assertEqual(self.client.get(f"/api/components/{second.get_json()['id']}").get_json()["externalNo"], 2)
dup = self.client.post(
"/api/components",
json={
"name": "Дубль",
"type": "Добавки",
"dryMatter": 88.0,
"price": 0,
"externalNo": 1,
},
)
self.assertEqual(dup.status_code, 400)
self.assertIn("уже используется", dup.get_json()["message"])
manual = self.client.post(
"/api/components",
json={
"name": "Премикс",
"type": "Добавки",
"dryMatter": 95.0,
"price": 0,
"externalNo": 100,
},
)
self.assertEqual(manual.status_code, 201)
manual_id = manual.get_json()["id"]
self.assertEqual(self.client.get(f"/api/components/{manual_id}").get_json()["externalNo"], 100)
invalid = self.client.post(
"/api/components",
json={
"name": "Неверный номер",
"type": "Добавки",
"dryMatter": 95.0,
"price": 0,
"externalNo": 0,
},
)
self.assertEqual(invalid.status_code, 400)
self.assertIn("больше нуля", invalid.get_json()["message"])
listed = self.client.get("/api/components")
self.assertEqual(listed.status_code, 200)
self.assertTrue(any(x["id"] == cid for x in listed.get_json()))
one = self.client.get(f"/api/components/{cid}")
self.assertEqual(one.status_code, 200)
self.assertEqual(one.get_json()["name"], "Соя")
updated = self.client.put(
f"/api/components/{cid}",
json={
"name": "Соя обновл.",
"type": "Концентрированные",
"dryMatter": 90.0,
"price": 0,
"externalNo": 50,
},
)
self.assertEqual(updated.status_code, 200)
self.assertEqual(self.client.get(f"/api/components/{cid}").get_json()["externalNo"], 50)
bad = self.client.put(
f"/api/components/{cid}",
json={"externalNo": 100},
)
self.assertEqual(bad.status_code, 400)
deleted = self.client.delete(f"/api/components/{cid}")
self.assertEqual(deleted.status_code, 200)
gone = self.client.get(f"/api/components/{cid}")
self.assertEqual(gone.status_code, 404)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,43 @@
"""Страница /components — auth и HTML."""
from __future__ import annotations
import unittest
from app import create_app, db
from app.services.setup_state import mark_setup_complete
from tests.helpers.zootech_test_helpers import ZootechTestConfig, drain_response
class ComponentsPageServedTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_components_redirects_without_session(self) -> None:
resp = self.client.get("/components", follow_redirects=False)
self.assertEqual(resp.status_code, 302)
drain_response(resp)
def test_components_ok_after_login(self) -> None:
self.client.post(
"/api/auth/login",
json={"login": "zootech-test-admin", "password": "zootech-test-secret"},
)
resp = self.client.get("/components")
self.assertEqual(resp.status_code, 200)
self.assertIn(b"html", resp.data.lower())
drain_response(resp)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,24 @@
"""UI-контракт /components."""
from __future__ import annotations
import unittest
from tests.helpers.zootech_test_helpers import STATIC
class ComponentsUiContractTests(unittest.TestCase):
def setUp(self) -> None:
self.html = (STATIC / "components.html").read_text(encoding="utf-8")
def test_fetches_components_api(self) -> None:
self.assertIn("fetch('/api/components'", self.html)
self.assertIn("/api/components/feed-types", self.html)
self.assertIn("loadFeedTypes", self.html)
def test_has_component_list_markup(self) -> None:
self.assertIn('id="componentsList"', self.html)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,188 @@
"""Временная правка СВ% компонентов в плане на день."""
from __future__ import annotations
import unittest
from app import create_app, db
from app.models import Component, Ingredient, Recipe
from app.services.daily_plan.adjustments import (
adjust_component_norm,
get_component_adjustment_map,
list_component_norms_for_plan,
undo_component_norm_adjustment,
)
from app.services.daily_plan.builder import build_daily_plan
from app.services.daily_plan.replacements import replace_ingredient
from app.services.setup_state import mark_setup_complete
from tests.helpers.dispenser_recipe_fixtures import (
E2E_DISP_COMP,
E2E_DISP_ID,
E2E_DISP_RECIPE_1,
E2E_DISP_RECIPE_2,
seed_dispenser_period_recipes,
)
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class DailyPlanAdjustmentsTests(unittest.TestCase):
PLAN_DATE = "2026-06-07"
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
seed_dispenser_period_recipes()
alt = Component(
id="e2e-disp-comp-alt",
name="Alt Silo",
type="silage",
dry_matter=35.0,
protein=3.0,
energy=6.0,
price=0.0,
)
db.session.add(alt)
db.session.commit()
self.client.post(
"/api/auth/login",
json={"login": "zootech-test-admin", "password": "zootech-test-secret"},
)
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_adjustment_applies_to_all_trips_with_component(self) -> None:
adjust_component_norm(
E2E_DISP_COMP,
self.PLAN_DATE,
dry_matter=50.0,
)
plan = build_daily_plan(dispenser_id=E2E_DISP_ID, plan_date=self.PLAN_DATE)
trips = plan["periods"][0]["trips"]
self.assertEqual(len(trips), 2)
for trip in trips:
ing = trip["ingredients"][0]
self.assertTrue(ing["adjustedToday"])
self.assertEqual(ing["dryMatterPct"], 50.0)
master_wph = 1.0 if trip["recipeId"] == E2E_DISP_RECIPE_1 else 2.0
self.assertEqual(ing["weightPerHead"], master_wph)
self.assertAlmostEqual(ing["dryMatterPerHead"], master_wph * 0.5, places=4)
self.assertEqual(ing["originalWeightPerHead"], master_wph)
def test_master_ingredient_unchanged_after_adjustment(self) -> None:
adjust_component_norm(
E2E_DISP_COMP,
self.PLAN_DATE,
dry_matter=45.0,
)
ing = db.session.get(Ingredient, "e2e-disp-ing-1")
self.assertEqual(float(ing.weight_per_head), 1.0)
master = self.client.get(f"/api/recipes/{E2E_DISP_RECIPE_1}").get_json()
self.assertEqual(master["ingredients"][0]["weightPerHead"], 1.0)
self.assertNotIn("adjustedToday", master["ingredients"][0])
overlay = self.client.get(
f"/api/recipes/{E2E_DISP_RECIPE_1}?date={self.PLAN_DATE}"
).get_json()
self.assertTrue(overlay["ingredients"][0]["adjustedToday"])
self.assertEqual(overlay["ingredients"][0]["weightPerHead"], 1.0)
self.assertEqual(overlay["ingredients"][0]["dry_matter"], 45.0)
self.assertAlmostEqual(overlay["ingredients"][0]["dry_matter_per_head"], 0.45, places=4)
self.assertEqual(overlay["ingredients"][0]["originalWeightPerHead"], 1.0)
def test_component_norms_api_and_undo(self) -> None:
created = self.client.post(
"/api/daily-plan/adjustments/components",
json={
"componentId": E2E_DISP_COMP,
"dryMatter": 40.0,
"dryMatterLocked": True,
"date": self.PLAN_DATE,
"duration": "today",
},
)
self.assertEqual(created.status_code, 200, created.get_data(as_text=True))
listed = self.client.get(
f"/api/daily-plan/component-norms?dispenser_id={E2E_DISP_ID}&date={self.PLAN_DATE}"
).get_json()
self.assertEqual(len(listed["items"]), 1)
item = listed["items"][0]
self.assertEqual(item["componentId"], E2E_DISP_COMP)
self.assertTrue(item["adjustedToday"])
self.assertEqual(item["planDryMatterPct"], 40.0)
self.assertTrue(item["planDryMatterLocked"])
self.assertEqual(item["usageCount"], 2)
deleted = self.client.delete(
f"/api/daily-plan/adjustments/components?component_id={E2E_DISP_COMP}&date={self.PLAN_DATE}"
)
self.assertEqual(deleted.status_code, 200)
self.assertEqual(get_component_adjustment_map(self.PLAN_DATE), {})
plan = build_daily_plan(dispenser_id=E2E_DISP_ID, plan_date=self.PLAN_DATE)
ing = plan["periods"][0]["trips"][0]["ingredients"][0]
self.assertFalse(ing["adjustedToday"])
def test_adjustment_before_replacement_on_same_ingredient(self) -> None:
adjust_component_norm(
E2E_DISP_COMP,
self.PLAN_DATE,
dry_matter=50.0,
)
replace_ingredient(
E2E_DISP_RECIPE_1,
"e2e-disp-ing-1",
"e2e-disp-comp-alt",
self.PLAN_DATE,
)
plan = build_daily_plan(dispenser_id=E2E_DISP_ID, plan_date=self.PLAN_DATE)
ing = plan["periods"][0]["trips"][0]["ingredients"][0]
self.assertTrue(ing["replacedToday"])
self.assertTrue(ing["adjustedToday"])
self.assertEqual(ing["weightPerHead"], 1.0)
self.assertEqual(ing["dryMatterPct"], 35.0)
def test_locked_mode_adjustment_keeps_dry_matter_per_head(self) -> None:
recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1)
self.assertIsNotNone(recipe)
recipe.dry_matter_locked = True
db.session.commit()
adjust_component_norm(
E2E_DISP_COMP,
self.PLAN_DATE,
dry_matter=40.0,
)
plan = build_daily_plan(dispenser_id=E2E_DISP_ID, plan_date=self.PLAN_DATE)
ing = plan["periods"][0]["trips"][0]["ingredients"][0]
self.assertAlmostEqual(ing["dryMatterPerHead"], 0.55, places=4)
self.assertAlmostEqual(ing["weightPerHead"], 1.38, places=2)
self.assertEqual(ing["dryMatterPct"], 40.0)
def test_undo_service(self) -> None:
adjust_component_norm(E2E_DISP_COMP, self.PLAN_DATE, dry_matter=42.0)
self.assertTrue(
undo_component_norm_adjustment(E2E_DISP_COMP, self.PLAN_DATE)
)
self.assertEqual(get_component_adjustment_map(self.PLAN_DATE), {})
def test_list_component_norms_for_plan(self) -> None:
rows = list_component_norms_for_plan(self.PLAN_DATE, dispenser_id=E2E_DISP_ID)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["usageCount"], 2)
adjust_component_norm(E2E_DISP_COMP, self.PLAN_DATE, dry_matter=48.0)
rows2 = list_component_norms_for_plan(self.PLAN_DATE, dispenser_id=E2E_DISP_ID)
self.assertTrue(rows2[0]["adjustedToday"])
self.assertEqual(rows2[0]["planDryMatterPct"], 48.0)
if __name__ == "__main__":
unittest.main()
+71
View File
@@ -0,0 +1,71 @@
"""API /api/daily-plan — JSON и PDF."""
from __future__ import annotations
import unittest
from app import create_app, db
from app.services.setup_state import mark_setup_complete
from app.services.daily_plan.builder import ALL_DISPENSERS_ID
from tests.helpers.dispenser_recipe_fixtures import E2E_DISP_ID, seed_dispenser_period_recipes
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class DailyPlanApiTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
seed_dispenser_period_recipes()
self.client.post(
"/api/auth/login",
json={"login": "zootech-test-admin", "password": "zootech-test-secret"},
)
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_get_daily_plan_json(self) -> None:
resp = self.client.get(
f"/api/daily-plan?dispenser_id={E2E_DISP_ID}&date=2026-06-07"
)
self.assertEqual(resp.status_code, 200, resp.get_data(as_text=True))
body = resp.get_json()
self.assertEqual(body["date"], "2026-06-07")
self.assertEqual(body["dispenserId"], E2E_DISP_ID)
self.assertTrue(body["periods"])
self.assertTrue(body["ingredientTotals"])
def test_get_daily_plan_all_dispensers(self) -> None:
resp = self.client.get(
f"/api/daily-plan?dispenser_id={ALL_DISPENSERS_ID}&date=2026-06-07"
)
self.assertEqual(resp.status_code, 200, resp.get_data(as_text=True))
self.assertEqual(resp.get_json()["dispenserName"], "Все кормораздатчики")
def test_get_daily_plan_requires_dispenser_id(self) -> None:
resp = self.client.get("/api/daily-plan")
self.assertEqual(resp.status_code, 400)
self.assertIn("dispenser_id", resp.get_json()["message"])
def test_get_daily_plan_404_unknown_dispenser(self) -> None:
resp = self.client.get("/api/daily-plan?dispenser_id=unknown")
self.assertEqual(resp.status_code, 404)
def test_get_daily_plan_pdf(self) -> None:
resp = self.client.get(
f"/api/daily-plan/pdf?dispenser_id={E2E_DISP_ID}&date=2026-06-07"
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content_type, "application/pdf")
self.assertTrue(resp.data.startswith(b"%PDF"))
def test_daily_plan_requires_auth(self) -> None:
anon = self.app.test_client()
resp = anon.get(f"/api/daily-plan?dispenser_id={E2E_DISP_ID}")
self.assertIn(resp.status_code, (401, 403))
+201
View File
@@ -0,0 +1,201 @@
"""Unit-тесты daily_plan/builder.py."""
from __future__ import annotations
import unittest
from app import create_app, db
from app.models import Component, FeedDispenser, Ingredient, Recipe
from app.services.daily_plan.builder import (
ALL_DISPENSERS_ID,
ALL_MILLS_ID,
build_daily_plan,
)
from app.services.setup_state import mark_setup_complete
from tests.helpers.dispenser_recipe_fixtures import (
E2E_DISP_COMP,
E2E_DISP_ID,
E2E_DISP_RECIPE_1,
E2E_PERIOD_A,
seed_dispenser_period_recipes,
)
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class DailyPlanBuilderTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_build_aggregates_periods_trips_and_totals(self) -> None:
seed_dispenser_period_recipes()
plan = build_daily_plan(dispenser_id=E2E_DISP_ID, plan_date="2026-06-07")
self.assertEqual(plan["date"], "2026-06-07")
self.assertEqual(plan["dispenserId"], E2E_DISP_ID)
self.assertEqual(plan["dispenserName"], "E2E Раздатчик")
self.assertEqual(plan["dispenserType"], "dispenser")
self.assertIn("skippedTrips", plan)
self.assertEqual(plan["skippedTrips"], [])
periods = plan["periods"]
self.assertEqual(len(periods), 2)
morning = next(p for p in periods if p["id"] == E2E_PERIOD_A)
self.assertEqual(morning["name"], "E2E Утро")
self.assertEqual(len(morning["trips"]), 2)
trip1 = morning["trips"][0]
self.assertEqual(trip1["recipeId"], E2E_DISP_RECIPE_1)
self.assertEqual(trip1["headsPerTrip"], 20)
self.assertEqual(trip1["mixingTimeSec"], 3)
self.assertEqual(len(trip1["ingredients"]), 1)
self.assertEqual(trip1["ingredients"][0]["name"], "Ing 1")
self.assertEqual(trip1["ingredients"][0]["totalKg"], 20.0)
self.assertEqual(len(trip1["unloadingGroups"]), 1)
self.assertEqual(trip1["unloadingGroups"][0]["name"], "Г1")
self.assertEqual(trip1["unloadingGroups"][0]["weightKg"], 20.0)
self.assertEqual(trip1["totalWeightKg"], 20.0)
self.assertEqual(trip1["unloadingTotalKg"], 20.0)
totals = {row["name"]: row["totalKg"] for row in plan["ingredientTotals"]}
self.assertEqual(totals["Ing 1"], 20.0)
self.assertEqual(totals["Ing 2"], 30.0)
def test_all_dispensers_aggregates_with_prefixed_periods(self) -> None:
seed_dispenser_period_recipes()
plan = build_daily_plan(dispenser_id=ALL_DISPENSERS_ID, plan_date="2026-06-07")
self.assertEqual(plan["dispenserName"], "Все кормораздатчики")
self.assertTrue(plan["periods"])
self.assertTrue(any("E2E Раздатчик" in p["name"] for p in plan["periods"]))
self.assertTrue(plan["ingredientTotals"])
def test_all_mills_returns_orphan_recipes(self) -> None:
plan = build_daily_plan(dispenser_id=ALL_MILLS_ID)
self.assertEqual(plan["dispenserName"], "Все кормоцеха")
self.assertEqual(plan["dispenserType"], "mill")
def test_unloading_group_has_distribution_label(self) -> None:
seed_dispenser_period_recipes()
plan = build_daily_plan(dispenser_id=E2E_DISP_ID)
group = plan["periods"][0]["trips"][0]["unloadingGroups"][0]
self.assertEqual(group["distributionType"], "percent")
self.assertIn("%", group["distributionLabel"])
def test_missing_dispenser_raises(self) -> None:
with self.assertRaises(LookupError):
build_daily_plan(dispenser_id="missing-disp")
def test_mill_fallback_uses_orphan_recipes(self) -> None:
mill_id = "mill-disp-1"
recipe_id = "mill-recipe-1"
comp_id = "mill-comp-1"
db.session.add_all(
[
FeedDispenser(
id=mill_id,
name="Мельница",
farm="Ферма",
operator="Тест",
type="mill",
content_hash="",
),
Component(
id=comp_id,
name="Компонент",
type="grain",
dry_matter=55.0,
protein=0.0,
energy=0.0,
price=0.0,
),
Recipe(
id=recipe_id,
name="Мельничный рейс",
heads_per_trip=10,
mixing_time=5,
trip_percent=100.0,
target_component_id=comp_id,
content_hash="",
),
]
)
db.session.flush()
db.session.add(
Ingredient(
id="mill-ing-1",
name="Зерно",
weight_per_head=2.5,
amount=0.0,
dry_matter=55.0,
component_id=comp_id,
order=1,
recipe_id=recipe_id,
created_by="system",
updated_by="system",
)
)
db.session.commit()
plan = build_daily_plan(dispenser_id=mill_id)
self.assertEqual(plan["dispenserType"], "mill")
self.assertEqual(len(plan["periods"]), 1)
self.assertEqual(plan["periods"][0]["name"], "Рейсы")
self.assertEqual(len(plan["periods"][0]["trips"]), 1)
self.assertEqual(plan["periods"][0]["trips"][0]["recipeName"], "Мельничный рейс")
self.assertEqual(plan["ingredientTotals"][0]["totalKg"], 25.0)
def test_unloading_weights_follow_trip_total_after_ingredient_skip(self) -> None:
from app.services.daily_plan.skips import skip_ingredient
seed_dispenser_period_recipes()
db.session.add(
Ingredient(
id="e2e-disp-ing-extra",
name="Extra",
weight_per_head=5.0,
amount=0.0,
dry_matter=55.0,
component_id=E2E_DISP_COMP,
order=2,
recipe_id=E2E_DISP_RECIPE_1,
created_by="system",
updated_by="system",
)
)
db.session.commit()
plan_before = build_daily_plan(dispenser_id=E2E_DISP_ID, plan_date="2026-06-07")
trip_before = plan_before["periods"][0]["trips"][0]
self.assertEqual(trip_before["totalWeightKg"], 120.0)
self.assertEqual(trip_before["unloadingGroups"][0]["weightKg"], 120.0)
skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-extra", "2026-06-07")
plan_after = build_daily_plan(dispenser_id=E2E_DISP_ID, plan_date="2026-06-07")
trip_after = plan_after["periods"][0]["trips"][0]
self.assertEqual(trip_after["totalWeightKg"], 20.0)
self.assertEqual(trip_after["unloadingGroups"][0]["weightKg"], 20.0)
self.assertEqual(trip_after["unloadingTotalKg"], 20.0)
def test_skipped_ingredient_includes_baseline_weights(self) -> None:
from app.services.daily_plan.skips import skip_ingredient
seed_dispenser_period_recipes()
skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", "2026-06-07")
plan = build_daily_plan(dispenser_id=E2E_DISP_ID, plan_date="2026-06-07")
skipped = next(
ing
for trip in plan["periods"][0]["trips"]
for ing in trip["ingredients"]
if ing.get("skippedToday")
)
self.assertEqual(skipped["totalKg"], 0.0)
self.assertEqual(skipped["baselineWeightPerHead"], 1.0)
self.assertEqual(skipped["baselineTotalKg"], 20.0)
@@ -0,0 +1,124 @@
"""Unit-тесты пересчёта весов при замене компонента в плане."""
from __future__ import annotations
import unittest
from app.models import Component, Ingredient, Recipe
from app.services.daily_plan.ingredient_weights import resolve_plan_ingredient_weights
class PlanIngredientWeightsTests(unittest.TestCase):
def _make(self, *, locked: bool, wph: float, dm_pct: float, repl_dm: float, dm_ph=None):
recipe = Recipe(id="r1", name="R", dry_matter_locked=locked, content_hash="")
ing = Ingredient(
id="i1",
name="Ing",
weight_per_head=wph,
amount=0.0,
dry_matter=dm_pct,
dry_matter_per_head=dm_ph,
order=1,
recipe_id="r1",
component_id="c1",
created_by="system",
updated_by="system",
)
components = {
"c1": Component(id="c1", name="A", type="grain", dry_matter=dm_pct),
"c2": Component(id="c2", name="B", type="silage", dry_matter=repl_dm),
}
return ing, recipe, components
def test_weight_mode_keeps_kg_per_head(self) -> None:
ing, recipe, components = self._make(locked=False, wph=2.0, dm_pct=55.0, repl_dm=35.0)
out = resolve_plan_ingredient_weights(
ing, recipe, heads=10, components_by_id=components, replacement_component_id="c2"
)
self.assertEqual(out["recalculationMode"], "weight")
self.assertEqual(out["weightPerHead"], 2.0)
self.assertEqual(out["totalKg"], 20.0)
self.assertEqual(out["dryMatterPct"], 35.0)
self.assertAlmostEqual(out["dryMatterPerHead"], 0.7, places=4)
def test_dry_matter_mode_keeps_dm_per_head(self) -> None:
ing, recipe, components = self._make(
locked=True, wph=1.0, dm_pct=55.0, repl_dm=35.0, dm_ph=0.55
)
out = resolve_plan_ingredient_weights(
ing, recipe, heads=20, components_by_id=components, replacement_component_id="c2"
)
self.assertEqual(out["recalculationMode"], "dry_matter")
self.assertAlmostEqual(out["weightPerHead"], 1.57, places=2)
self.assertAlmostEqual(out["totalKg"], 31.4, places=1)
self.assertAlmostEqual(out["dryMatterPerHead"], 0.55, places=4)
def test_dry_matter_pct_adjustment_locked(self) -> None:
ing, recipe, components = self._make(
locked=True, wph=1.0, dm_pct=55.0, repl_dm=35.0, dm_ph=0.55
)
out = resolve_plan_ingredient_weights(
ing,
recipe,
heads=10,
components_by_id=components,
component_adjustment={
"dry_matter": 40.0,
"dry_matter_locked": True,
"weight_per_head": None,
"dry_matter_per_head": None,
},
)
self.assertTrue(out["adjustedToday"])
self.assertEqual(out["dryMatterPct"], 40.0)
self.assertAlmostEqual(out["dryMatterPerHead"], 0.55, places=4)
self.assertAlmostEqual(out["weightPerHead"], 1.38, places=2)
def test_dry_matter_pct_adjustment(self) -> None:
ing, recipe, components = self._make(locked=False, wph=2.0, dm_pct=55.0, repl_dm=35.0)
out = resolve_plan_ingredient_weights(
ing,
recipe,
heads=10,
components_by_id=components,
component_adjustment={"dry_matter": 40.0, "weight_per_head": None, "dry_matter_per_head": None},
)
self.assertTrue(out["adjustedToday"])
self.assertEqual(out["weightPerHead"], 2.0)
self.assertEqual(out["dryMatterPct"], 40.0)
self.assertAlmostEqual(out["dryMatterPerHead"], 0.8, places=4)
self.assertEqual(out["originalWeightPerHead"], 2.0)
self.assertEqual(out["originalDryMatterPct"], 55.0)
def test_weight_mode_adjustment(self) -> None:
ing, recipe, components = self._make(locked=False, wph=2.0, dm_pct=55.0, repl_dm=35.0)
out = resolve_plan_ingredient_weights(
ing,
recipe,
heads=10,
components_by_id=components,
component_adjustment={"weight_per_head": 3.0, "dry_matter_per_head": None},
)
self.assertTrue(out["adjustedToday"])
self.assertEqual(out["weightPerHead"], 3.0)
self.assertAlmostEqual(out["dryMatterPerHead"], 1.65, places=4)
self.assertEqual(out["originalWeightPerHead"], 2.0)
def test_dry_matter_mode_adjustment(self) -> None:
ing, recipe, components = self._make(
locked=True, wph=1.0, dm_pct=55.0, repl_dm=35.0, dm_ph=0.55
)
out = resolve_plan_ingredient_weights(
ing,
recipe,
heads=20,
components_by_id=components,
component_adjustment={"weight_per_head": None, "dry_matter_per_head": 0.7},
)
self.assertTrue(out["adjustedToday"])
self.assertAlmostEqual(out["dryMatterPerHead"], 0.7, places=4)
self.assertAlmostEqual(out["weightPerHead"], 1.27, places=2)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,134 @@
"""API skip компонентов и групп выгрузки."""
from __future__ import annotations
import unittest
from datetime import date
from app import create_app, db
from app.services.daily_plan.skips import skip_ingredient, skip_unloading_group
from app.services.setup_state import mark_setup_complete
from tests.helpers.dispenser_recipe_fixtures import (
E2E_DISP_RECIPE_1,
E2E_PERIOD_A,
seed_dispenser_period_recipes,
)
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class DailyPlanPartSkipApiTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
seed_dispenser_period_recipes()
self.client.post(
"/api/auth/login",
json={"login": "zootech-test-admin", "password": "zootech-test-secret"},
)
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_get_skips_returns_grouped_payload(self) -> None:
skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", "2026-06-07")
skip_unloading_group(E2E_DISP_RECIPE_1, "e2e-disp-grp-1", "2026-06-07")
resp = self.client.get("/api/daily-plan/skips?date=2026-06-07")
self.assertEqual(resp.status_code, 200)
body = resp.get_json()
self.assertIn("trips", body)
self.assertIn("ingredients", body)
self.assertIn("unloadingGroups", body)
self.assertEqual(len(body["ingredients"]), 1)
self.assertEqual(len(body["unloadingGroups"]), 1)
def test_post_and_delete_ingredient_skip(self) -> None:
resp = self.client.post(
"/api/daily-plan/skips/ingredients",
json={
"recipeId": E2E_DISP_RECIPE_1,
"ingredientId": "e2e-disp-ing-1",
"date": "2026-06-08",
},
)
self.assertEqual(resp.status_code, 200, resp.get_data(as_text=True))
deleted = self.client.delete(
"/api/daily-plan/skips/ingredients"
f"?recipe_id={E2E_DISP_RECIPE_1}&ingredient_id=e2e-disp-ing-1&date=2026-06-08"
)
self.assertEqual(deleted.status_code, 200)
def test_recipe_detail_marks_skipped_ingredient_for_zootech(self) -> None:
skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", "2026-06-09")
resp = self.client.get(f"/api/recipes/{E2E_DISP_RECIPE_1}?date=2026-06-09")
self.assertEqual(resp.status_code, 200)
ingredients = resp.get_json()["ingredients"]
skipped = [i for i in ingredients if i.get("skippedToday")]
self.assertEqual(len(skipped), 1)
self.assertEqual(skipped[0]["id"], "e2e-disp-ing-1")
def test_period_recipes_show_skipped_ingredient_flag(self) -> None:
from app.services.daily_plan.skips import skip_ingredient
today = date.today().isoformat()
skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", today)
resp = self.client.get(f"/api/periods/{E2E_PERIOD_A}/recipes")
self.assertEqual(resp.status_code, 200)
recipe = next(r for r in resp.get_json() if r["id"] == E2E_DISP_RECIPE_1)
self.assertTrue(recipe.get("skippedIngredientToday"))
def test_post_ingredient_skip_week_duration(self) -> None:
resp = self.client.post(
"/api/daily-plan/skips/ingredients",
json={
"recipeId": E2E_DISP_RECIPE_1,
"ingredientId": "e2e-disp-ing-1",
"date": "2026-06-08",
"duration": "week",
},
)
self.assertEqual(resp.status_code, 200, resp.get_data(as_text=True))
body = resp.get_json()
self.assertEqual(body.get("validUntil"), "2026-06-14")
detail = self.client.get(f"/api/recipes/{E2E_DISP_RECIPE_1}?date=2026-06-10")
skipped = [i for i in detail.get_json()["ingredients"] if i.get("skippedToday")]
self.assertEqual(len(skipped), 1)
def test_delete_all_ingredient_skips_for_recipe(self) -> None:
skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", "2026-06-07")
resp = self.client.delete(
f"/api/daily-plan/skips/ingredients?recipe_id={E2E_DISP_RECIPE_1}&date=2026-06-07&all=1"
)
self.assertEqual(resp.status_code, 200)
self.assertGreaterEqual(resp.get_json().get("count", 0), 1)
detail = self.client.get(f"/api/recipes/{E2E_DISP_RECIPE_1}?date=2026-06-07")
skipped = [i for i in detail.get_json()["ingredients"] if i.get("skippedToday")]
self.assertEqual(skipped, [])
def test_feed_dispenser_has_skip_today_flag(self) -> None:
today = date.today().isoformat()
skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", today)
resp = self.client.get("/api/feed_dispensers?limit=50")
self.assertEqual(resp.status_code, 200)
dispensers = resp.get_json()
flagged = [d for d in dispensers if d.get("hasSkipToday")]
self.assertTrue(flagged)
def test_recipe_detail_hides_skipped_ingredient_for_kiosk(self) -> None:
skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", "2026-06-09")
resp = self.client.get(
f"/api/recipes/{E2E_DISP_RECIPE_1}?date=2026-06-09",
headers={"X-Wesp-Kiosk": "1"},
)
self.assertEqual(resp.status_code, 200)
ids = {i["id"] for i in resp.get_json()["ingredients"]}
self.assertNotIn("e2e-disp-ing-1", ids)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,69 @@
"""Unit-тесты skip компонентов и групп выгрузки."""
from __future__ import annotations
import unittest
from app import create_app, db
from app.services.daily_plan.builder import build_daily_plan
from app.services.daily_plan.skips import (
skip_ingredient,
skip_unloading_group,
unskip_ingredient,
unskip_unloading_group,
)
from app.services.setup_state import mark_setup_complete
from tests.helpers.dispenser_recipe_fixtures import (
E2E_DISP_ID,
E2E_DISP_RECIPE_1,
E2E_PERIOD_A,
seed_dispenser_period_recipes,
)
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class DailyPlanPartSkipServiceTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
seed_dispenser_period_recipes()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_skip_and_unskip_ingredient(self) -> None:
plan_date = "2026-06-10"
row = skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", plan_date)
self.assertEqual(row.ingredient_id, "e2e-disp-ing-1")
self.assertTrue(unskip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", plan_date))
def test_skip_and_unskip_unloading_group(self) -> None:
plan_date = "2026-06-11"
row = skip_unloading_group(E2E_DISP_RECIPE_1, "e2e-disp-grp-1", plan_date)
self.assertEqual(row.unloading_group_id, "e2e-disp-grp-1")
self.assertTrue(
unskip_unloading_group(E2E_DISP_RECIPE_1, "e2e-disp-grp-1", plan_date)
)
def test_builder_excludes_skipped_parts(self) -> None:
plan_date = "2026-06-12"
skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", plan_date)
skip_unloading_group(E2E_DISP_RECIPE_1, "e2e-disp-grp-1", plan_date)
plan = build_daily_plan(dispenser_id=E2E_DISP_ID, plan_date=plan_date)
morning = next(p for p in plan["periods"] if p["id"] == E2E_PERIOD_A)
trip = next(t for t in morning["trips"] if t["recipeId"] == E2E_DISP_RECIPE_1)
self.assertEqual(trip["ingredients"][0]["skippedToday"], True)
self.assertEqual(trip["unloadingGroups"][0]["skippedToday"], True)
self.assertEqual(len(plan["skippedIngredients"]), 1)
self.assertEqual(len(plan["skippedUnloadingGroups"]), 1)
totals = {row["name"]: row["totalKg"] for row in plan["ingredientTotals"]}
self.assertNotIn("Ing 1", totals)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,145 @@
"""Замена компонентов в плане на день."""
from __future__ import annotations
import unittest
from app import create_app, db
from app.models import Component
from app.services.daily_plan.builder import build_daily_plan
from app.services.daily_plan.replacements import replace_ingredient
from app.services.setup_state import mark_setup_complete
from tests.helpers.dispenser_recipe_fixtures import (
E2E_DISP_COMP,
E2E_DISP_ID,
E2E_DISP_RECIPE_1,
seed_dispenser_period_recipes,
)
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class DailyPlanReplacementTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
seed_dispenser_period_recipes()
self.client.post(
"/api/auth/login",
json={"login": "zootech-test-admin", "password": "zootech-test-secret"},
)
alt = Component(
id="e2e-disp-comp-alt",
name="Alt Silo",
type="silage",
dry_matter=35.0,
protein=3.0,
energy=6.0,
price=0.0,
)
db.session.add(alt)
db.session.commit()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_replace_ingredient_shows_in_plan_and_totals(self) -> None:
replace_ingredient(
E2E_DISP_RECIPE_1,
"e2e-disp-ing-1",
"e2e-disp-comp-alt",
"2026-06-07",
)
plan = build_daily_plan(dispenser_id=E2E_DISP_ID, plan_date="2026-06-07")
trip = plan["periods"][0]["trips"][0]
ing = trip["ingredients"][0]
self.assertTrue(ing["replacedToday"])
self.assertIn("Alt Silo", ing["name"])
totals = {row["name"]: row["totalKg"] for row in plan["ingredientTotals"]}
self.assertEqual(totals["Alt Silo"], 20.0)
self.assertEqual(plan["ingredientGrandTotalKg"], 50.0)
def test_replace_recalculates_by_dry_matter_when_locked(self) -> None:
from app.models import Recipe
recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1)
recipe.dry_matter_locked = True
db.session.commit()
replace_ingredient(
E2E_DISP_RECIPE_1,
"e2e-disp-ing-1",
"e2e-disp-comp-alt",
"2026-06-07",
)
plan = build_daily_plan(dispenser_id=E2E_DISP_ID, plan_date="2026-06-07")
ing = plan["periods"][0]["trips"][0]["ingredients"][0]
self.assertEqual(ing["recalculationMode"], "dry_matter")
self.assertAlmostEqual(ing["weightPerHead"], 1.57, places=2)
self.assertAlmostEqual(ing["totalKg"], 31.4, places=1)
totals = {row["name"]: row["totalKg"] for row in plan["ingredientTotals"]}
self.assertAlmostEqual(totals["Alt Silo"], 31.4, places=1)
self.assertAlmostEqual(plan["ingredientGrandTotalKg"], 61.4, places=1)
def test_component_alternatives_api(self) -> None:
resp = self.client.get(
f"/api/daily-plan/component-alternatives?component_id={E2E_DISP_COMP}"
)
self.assertEqual(resp.status_code, 200, resp.get_data(as_text=True))
body = resp.get_json()
self.assertIn("similar", body)
self.assertIn("results", body)
self.assertTrue(body["results"])
def test_replace_component_in_all_plan_recipes(self) -> None:
created = self.client.post(
"/api/daily-plan/replacements/components",
json={
"componentId": E2E_DISP_COMP,
"replacementComponentId": "e2e-disp-comp-alt",
"dispenserId": E2E_DISP_ID,
"date": "2026-06-09",
},
)
self.assertEqual(created.status_code, 200, created.get_data(as_text=True))
body = created.get_json()
self.assertEqual(body.get("count"), 2)
plan = build_daily_plan(dispenser_id=E2E_DISP_ID, plan_date="2026-06-09")
replaced = 0
for period in plan["periods"]:
for trip in period["trips"]:
for ing in trip["ingredients"]:
if ing.get("originalComponentId") == E2E_DISP_COMP:
self.assertTrue(ing["replacedToday"])
self.assertIn("Alt Silo", ing["name"])
replaced += 1
self.assertEqual(replaced, 2)
def test_post_and_delete_replacement(self) -> None:
created = self.client.post(
"/api/daily-plan/replacements/ingredients",
json={
"recipeId": E2E_DISP_RECIPE_1,
"ingredientId": "e2e-disp-ing-1",
"replacementComponentId": "e2e-disp-comp-alt",
"date": "2026-06-08",
},
)
self.assertEqual(created.status_code, 200, created.get_data(as_text=True))
listed = self.client.get("/api/notifications?category=daily_plan").get_json()
self.assertTrue(any("" in x.get("title", "") for x in listed["items"]))
deleted = self.client.delete(
"/api/daily-plan/replacements/ingredients"
f"?recipe_id={E2E_DISP_RECIPE_1}&ingredient_id=e2e-disp-ing-1&date=2026-06-08"
)
self.assertEqual(deleted.status_code, 200)
listed2 = self.client.get("/api/notifications?category=daily_plan").get_json()
self.assertTrue(any("снова в плане" in x.get("title", "") for x in listed2["items"]))
if __name__ == "__main__":
unittest.main()
+414
View File
@@ -0,0 +1,414 @@
"""Sync всех daily_* таблиц + оператор загрузки vs мастер-рацион."""
from __future__ import annotations
import os
import tempfile
import unittest
from datetime import date
from sqlalchemy import select
from app import create_app, db
from app.models import (
Component,
Ingredient,
Recipe,
WESP_SUPPRESS_SYNC_ENQUEUE,
DailyComponentNormAdjustment,
DailyIngredientReplacement,
DailyIngredientSkip,
DailyTripSkip,
DailyUnloadingGroupSkip,
SyncQueue,
)
from app.services.daily_plan.adjustments import adjust_component_norm
from app.services.daily_plan.replacements import replace_ingredient
from app.services.daily_plan.skips import (
skip_ingredient,
skip_trip,
skip_unloading_group,
)
from app.services.setup_state import mark_setup_complete
from app.services.sync_manager import apply_sync_change
from app.services.sync_record_data import get_record_data_for_sync
from config import TestingConfig
from tests.helpers.daily_plan_operator_helpers import (
DAILY_PLAN_SYNC_TABLES,
assert_daily_plan_tables_registered,
assert_operator_matches_plan_not_master,
first_ingredient_amount,
master_recipe_json,
operator_weight_display,
plan_recipe_json,
plan_date_today,
)
from tests.helpers.dispenser_recipe_fixtures import (
E2E_DISP_COMP,
E2E_DISP_ID,
E2E_DISP_RECIPE_1,
E2E_DISP_RECIPE_2,
E2E_PERIOD_A,
seed_dispenser_period_recipes,
)
from tests.helpers.sync_dual_harness import SyncDualInstanceHarness
from tests.helpers.zootech_test_helpers import ZootechTestConfig
def _add_second_ingredient() -> None:
db.session.add(
Ingredient(
id="e2e-disp-ing-extra",
name="Ing Extra",
weight_per_head=3.0,
amount=30.0,
dry_matter=55.0,
component_id=E2E_DISP_COMP,
order=2,
recipe_id=E2E_DISP_RECIPE_1,
created_by="system",
updated_by="system",
)
)
def _alt_component(component_id: str = "e2e-disp-comp-alt-sync") -> Component:
return Component(
id=component_id,
name="Alt Silo Sync",
type="silage",
dry_matter=35.0,
protein=3.0,
energy=6.0,
price=0.0,
)
class DailyPlanSyncRegistryTests(unittest.TestCase):
def test_all_daily_plan_tables_in_sync_pipeline(self) -> None:
assert_daily_plan_tables_registered()
class DailyPlanSyncApplyTests(unittest.TestCase):
"""apply_sync_change для каждой daily_* таблицы."""
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
seed_dispenser_period_recipes()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def _apply_roundtrip(self, table_name: str, row, *, recreate: bool = True) -> None:
payload = get_record_data_for_sync(table_name, row.id)
self.assertIsNotNone(payload, table_name)
if recreate:
db.session.delete(row)
db.session.commit()
db.session.info[WESP_SUPPRESS_SYNC_ENQUEUE] = True
try:
res = apply_sync_change(table_name, row.id, "create", payload or {})
finally:
db.session.info.pop(WESP_SUPPRESS_SYNC_ENQUEUE, None)
self.assertTrue(res["success"], res)
def test_apply_sync_daily_trip_skip(self) -> None:
row = skip_trip(E2E_DISP_RECIPE_1, "2026-06-07")
self._apply_roundtrip("daily_trip_skip", row)
restored = db.session.get(DailyTripSkip, row.id)
self.assertIsNotNone(restored)
self.assertEqual(restored.recipe_id, E2E_DISP_RECIPE_1)
def test_apply_sync_daily_ingredient_skip(self) -> None:
row = skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", "2026-06-07")
self._apply_roundtrip("daily_ingredient_skip", row)
restored = db.session.get(DailyIngredientSkip, row.id)
self.assertIsNotNone(restored)
def test_apply_sync_daily_unloading_group_skip(self) -> None:
row = skip_unloading_group(E2E_DISP_RECIPE_1, "e2e-disp-grp-1", "2026-06-07")
self._apply_roundtrip("daily_unloading_group_skip", row)
restored = db.session.get(DailyUnloadingGroupSkip, row.id)
self.assertIsNotNone(restored)
def test_apply_sync_daily_ingredient_replacement(self) -> None:
db.session.add(_alt_component())
db.session.commit()
row = replace_ingredient(
E2E_DISP_RECIPE_1,
"e2e-disp-ing-1",
"e2e-disp-comp-alt-sync",
"2026-06-07",
)
self._apply_roundtrip("daily_ingredient_replacement", row)
restored = db.session.get(DailyIngredientReplacement, row.id)
self.assertIsNotNone(restored)
def test_apply_sync_daily_component_norm_adjustment(self) -> None:
row = adjust_component_norm(E2E_DISP_COMP, "2026-06-07", dry_matter=48.0)
self._apply_roundtrip("daily_component_norm_adjustment", row)
restored = db.session.get(DailyComponentNormAdjustment, row.id)
self.assertIsNotNone(restored)
self.assertEqual(float(restored.dry_matter or 0), 48.0)
class DailyPlanSyncClientOperatorTests(unittest.TestCase):
"""Изменение плана на сервере → sync на клиент → оператор видит план, не мастер."""
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
seed_dispenser_period_recipes()
_add_second_ingredient()
db.session.commit()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def _client_app(self):
tmp = tempfile.mkdtemp(prefix="wesp-daily-plan-client-")
client_db = os.path.join(tmp, "client.db")
class ClientCfg(TestingConfig):
SQLALCHEMY_DATABASE_URI = f"sqlite:///{client_db}"
SQLALCHEMY_BINDS = {
"reports": f"sqlite:///{client_db.replace('.db', '_reports.db')}"
}
client_app = create_app(ClientCfg)
ctx = client_app.app_context()
ctx.push()
db.create_all()
mark_setup_complete(client_app)
seed_dispenser_period_recipes()
_add_second_ingredient()
db.session.commit()
return client_app, ctx
def _sync_row_to_client(self, table_name: str, row) -> tuple:
payload = get_record_data_for_sync(table_name, row.id)
self.assertIsNotNone(payload)
client_app, client_ctx = self._client_app()
db.session.info[WESP_SUPPRESS_SYNC_ENQUEUE] = True
try:
with client_app.app_context():
res = apply_sync_change(table_name, row.id, "create", payload or {})
self.assertTrue(res["success"], res)
db.session.commit()
finally:
db.session.info.pop(WESP_SUPPRESS_SYNC_ENQUEUE, None)
return client_app, client_ctx
def test_replacement_sync_operator_sees_plan_not_master(self) -> None:
db.session.add(_alt_component("e2e-disp-comp-alt-loading"))
recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1)
recipe.dry_matter_locked = True
db.session.commit()
today = plan_date_today()
row = replace_ingredient(
E2E_DISP_RECIPE_1,
"e2e-disp-ing-1",
"e2e-disp-comp-alt-loading",
today,
)
client_app, client_ctx = self._sync_row_to_client(
"daily_ingredient_replacement", row
)
try:
with client_app.app_context():
client_recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1)
client_recipe.dry_matter_locked = True
db.session.commit()
kiosk = client_app.test_client()
result = assert_operator_matches_plan_not_master(
kiosk, E2E_DISP_RECIPE_1, plan_date=today
)
self.assertGreater(result["plan_amounts"][0], result["master_amounts"][0])
finally:
db.session.remove()
db.drop_all()
client_ctx.pop()
recipe.dry_matter_locked = False
db.session.commit()
def test_ingredient_skip_sync_operator_sees_second_component(self) -> None:
today = plan_date_today()
row = skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", today)
client_app, client_ctx = self._sync_row_to_client("daily_ingredient_skip", row)
try:
kiosk = client_app.test_client()
master = master_recipe_json(kiosk, E2E_DISP_RECIPE_1)
plan = plan_recipe_json(kiosk, E2E_DISP_RECIPE_1, plan_date=today)
operator = operator_weight_display(kiosk, E2E_DISP_RECIPE_1)
self.assertEqual(len(master["ingredients"]), 2)
self.assertEqual(len(plan["ingredients"]), 1)
self.assertEqual(plan["ingredients"][0]["id"], "e2e-disp-ing-extra")
plan_amount = float(plan["ingredients"][0]["amount"])
self.assertNotEqual(int(round(plan_amount)), int(round(first_ingredient_amount(master))))
self.assertEqual(operator["total_component"], int(round(plan_amount)))
finally:
db.session.remove()
db.drop_all()
client_ctx.pop()
def test_trip_skip_sync_hides_recipe_on_client_period_list(self) -> None:
today = plan_date_today()
row = skip_trip(E2E_DISP_RECIPE_1, today)
queued = db.session.execute(
select(SyncQueue).where(
SyncQueue.table_name == "daily_trip_skip",
SyncQueue.record_id == row.id,
SyncQueue.is_deleted.is_(False),
)
).scalars().all()
self.assertTrue(queued)
client_app, client_ctx = self._sync_row_to_client("daily_trip_skip", row)
try:
resp = client_app.test_client().get(f"/api/periods/{E2E_PERIOD_A}/recipes")
ids = {r["id"] for r in resp.get_json()}
self.assertNotIn(E2E_DISP_RECIPE_1, ids)
self.assertIn(E2E_DISP_RECIPE_2, ids)
finally:
db.session.remove()
db.drop_all()
client_ctx.pop()
def test_unloading_group_skip_sync_applies_on_client(self) -> None:
today = plan_date_today()
row = skip_unloading_group(E2E_DISP_RECIPE_1, "e2e-disp-grp-1", today)
client_app, client_ctx = self._sync_row_to_client("daily_unloading_group_skip", row)
try:
with client_app.app_context():
restored = db.session.get(DailyUnloadingGroupSkip, row.id)
self.assertIsNotNone(restored)
plan = plan_recipe_json(
client_app.test_client(), E2E_DISP_RECIPE_1, plan_date=today, kiosk=False
)
groups = plan.get("unloadingGroups") or plan.get("unloading_groups") or []
self.assertTrue(any(g.get("skippedToday") for g in groups))
finally:
db.session.remove()
db.drop_all()
client_ctx.pop()
def test_component_norm_adjustment_sync_marks_plan_on_client(self) -> None:
today = plan_date_today()
row = adjust_component_norm(E2E_DISP_COMP, today, dry_matter=42.0)
client_app, client_ctx = self._sync_row_to_client(
"daily_component_norm_adjustment", row
)
try:
plan = plan_recipe_json(
client_app.test_client(), E2E_DISP_RECIPE_1, plan_date=today, kiosk=False
)
ing = plan["ingredients"][0]
self.assertTrue(ing.get("adjustedToday"))
self.assertEqual(float(ing.get("dry_matter") or 0), 42.0)
master = master_recipe_json(client_app.test_client(), E2E_DISP_RECIPE_1)
self.assertNotIn("adjustedToday", master["ingredients"][0])
finally:
db.session.remove()
db.drop_all()
client_ctx.pop()
class DailyPlanSyncDualHarnessTests(unittest.TestCase):
"""Полный цикл: сервер → drain_sync → терминал → оператор."""
def setUp(self) -> None:
self.harness = SyncDualInstanceHarness()
self.harness.start()
with self.harness.server_ctx():
mark_setup_complete(self.harness.server_app)
seed_dispenser_period_recipes()
_add_second_ingredient()
db.session.add(_alt_component("e2e-disp-comp-dual-sync"))
recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1)
recipe.dry_matter_locked = True
db.session.commit()
self.harness.drain_sync()
def tearDown(self) -> None:
self.harness.stop()
def test_dual_sync_replacement_operator_matches_plan(self) -> None:
today = plan_date_today()
with self.harness.server_ctx():
row = replace_ingredient(
E2E_DISP_RECIPE_1,
"e2e-disp-ing-1",
"e2e-disp-comp-dual-sync",
today,
)
self.assertTrue(
self.harness.sync_queue_tasks("daily_ingredient_replacement", row.id)
)
self.harness.drain_sync()
kiosk = self.harness.term_a_app.test_client()
with self.harness.terminal_ctx("a"):
client_recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1)
client_recipe.dry_matter_locked = True
db.session.commit()
result = assert_operator_matches_plan_not_master(kiosk, E2E_DISP_RECIPE_1, plan_date=today)
self.assertGreater(result["plan_amounts"][0], result["master_amounts"][0])
with self.harness.terminal_ctx("b"):
repl = db.session.get(DailyIngredientReplacement, row.id)
self.assertIsNotNone(repl)
def test_dual_sync_ingredient_skip_both_terminals(self) -> None:
today = plan_date_today()
with self.harness.server_ctx():
row = skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", today)
row_id = row.id
self.harness.drain_sync()
for term in ("a", "b"):
kiosk = (
self.harness.term_a_app.test_client()
if term == "a"
else self.harness.term_b_app.test_client()
)
plan = plan_recipe_json(kiosk, E2E_DISP_RECIPE_1, plan_date=today)
self.assertEqual(len(plan["ingredients"]), 1)
operator = operator_weight_display(kiosk, E2E_DISP_RECIPE_1)
plan_amount = float(plan["ingredients"][0]["amount"])
self.assertEqual(operator["total_component"], int(round(plan_amount)))
with self.harness.terminal_ctx("a"):
self.assertIsNotNone(db.session.get(DailyIngredientSkip, row_id))
def test_dual_sync_trip_skip_period_recipes_on_both_terminals(self) -> None:
today = plan_date_today()
with self.harness.server_ctx():
skip_trip(E2E_DISP_RECIPE_1, today)
self.harness.drain_sync()
for term in ("a", "b"):
client = (
self.harness.term_a_app.test_client()
if term == "a"
else self.harness.term_b_app.test_client()
)
resp = client.get(f"/api/periods/{E2E_PERIOD_A}/recipes")
ids = {r["id"] for r in resp.get_json()}
self.assertNotIn(E2E_DISP_RECIPE_1, ids, msg=term)
if __name__ == "__main__":
unittest.main()
+212
View File
@@ -0,0 +1,212 @@
"""API /api/daily-plan/skips и фильтрация рейсов для терминала."""
from __future__ import annotations
import unittest
from datetime import date
from app import create_app, db
from app.services.daily_plan.skips import skip_trip
from app.services.setup_state import mark_setup_complete
from app.services.daily_plan.builder import recipe_total_weights_by_id
from tests.helpers.dispenser_recipe_fixtures import (
E2E_DISP_ID,
E2E_DISP_RECIPE_1,
E2E_DISP_RECIPE_2,
E2E_PERIOD_A,
seed_dispenser_period_recipes,
)
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class DailyTripSkipApiTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
seed_dispenser_period_recipes()
self.client.post(
"/api/auth/login",
json={"login": "zootech-test-admin", "password": "zootech-test-secret"},
)
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_post_and_delete_skip(self) -> None:
resp = self.client.post(
"/api/daily-plan/skips",
json={"recipeId": E2E_DISP_RECIPE_1, "date": "2026-06-07"},
)
self.assertEqual(resp.status_code, 200, resp.get_data(as_text=True))
body = resp.get_json()
self.assertEqual(body["recipeId"], E2E_DISP_RECIPE_1)
listed = self.client.get("/api/daily-plan/skips?date=2026-06-07")
self.assertEqual(listed.status_code, 200)
self.assertEqual(len(listed.get_json()["trips"]), 1)
deleted = self.client.delete(
f"/api/daily-plan/skips?recipe_id={E2E_DISP_RECIPE_1}&date=2026-06-07"
)
self.assertEqual(deleted.status_code, 200)
self.assertEqual(
self.client.get("/api/daily-plan/skips?date=2026-06-07").get_json()["trips"],
[],
)
def test_skip_requires_auth(self) -> None:
anon = self.app.test_client()
resp = anon.post(
"/api/daily-plan/skips",
json={"recipeId": E2E_DISP_RECIPE_1, "date": "2026-06-07"},
)
self.assertIn(resp.status_code, (401, 403))
def test_period_recipes_hide_skipped_for_terminal(self) -> None:
today = date.today().isoformat()
skip_trip(E2E_DISP_RECIPE_1, today)
anon = self.app.test_client()
resp = anon.get(f"/api/periods/{E2E_PERIOD_A}/recipes")
self.assertEqual(resp.status_code, 200, resp.get_data(as_text=True))
ids = {r["id"] for r in resp.get_json()}
self.assertNotIn(E2E_DISP_RECIPE_1, ids)
def test_period_recipes_show_skipped_flag_for_zootech(self) -> None:
today = date.today().isoformat()
skip_trip(E2E_DISP_RECIPE_1, today)
resp = self.client.get(f"/api/periods/{E2E_PERIOD_A}/recipes")
self.assertEqual(resp.status_code, 200)
skipped = [r for r in resp.get_json() if r.get("skippedToday")]
self.assertEqual(len(skipped), 1)
self.assertEqual(skipped[0]["id"], E2E_DISP_RECIPE_1)
def test_period_recipes_hide_skipped_for_kiosk_even_when_authenticated(self) -> None:
today = date.today().isoformat()
skip_trip(E2E_DISP_RECIPE_1, today)
resp = self.client.get(
f"/api/periods/{E2E_PERIOD_A}/recipes",
headers={"X-Wesp-Kiosk": "1"},
)
self.assertEqual(resp.status_code, 200)
ids = {r["id"] for r in resp.get_json()}
self.assertNotIn(E2E_DISP_RECIPE_1, ids)
def test_period_recipes_include_plan_total_weight_for_kiosk(self) -> None:
resp = self.app.test_client().get(
f"/api/periods/{E2E_PERIOD_A}/recipes",
headers={"X-Wesp-Kiosk": "1"},
)
self.assertEqual(resp.status_code, 200)
by_id = {r["id"]: r for r in resp.get_json()}
self.assertEqual(by_id[E2E_DISP_RECIPE_1]["total_weight"], 20.0)
self.assertEqual(by_id[E2E_DISP_RECIPE_2]["total_weight"], 30.0)
def test_kiosk_recipe_detail_uses_daily_plan_weights(self) -> None:
today = date.today().isoformat()
plan_weights = recipe_total_weights_by_id(dispenser_id=E2E_DISP_ID, plan_date=today)
resp = self.client.get(
f"/api/recipes/{E2E_DISP_RECIPE_1}?date={today}",
headers={"X-Wesp-Kiosk": "1"},
)
self.assertEqual(resp.status_code, 200)
data = resp.get_json()
self.assertEqual(data.get("total_weight"), plan_weights[E2E_DISP_RECIPE_1])
sum_ing = sum(float(i.get("amount") or 0) for i in data.get("ingredients") or [])
self.assertAlmostEqual(sum_ing, plan_weights[E2E_DISP_RECIPE_1], places=2)
def test_kiosk_recipe_detail_includes_plan_unloading_group_weights(self) -> None:
today = date.today().isoformat()
resp = self.client.get(
f"/api/recipes/{E2E_DISP_RECIPE_1}?date={today}",
headers={"X-Wesp-Kiosk": "1"},
)
self.assertEqual(resp.status_code, 200)
data = resp.get_json()
groups = data.get("unloadingGroups") or []
self.assertEqual(len(groups), 1)
self.assertEqual(groups[0]["weight"], data.get("total_weight"))
def test_kiosk_recipe_detail_respects_trip_percent_from_plan(self) -> None:
from app.models import Recipe
today = date.today().isoformat()
recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1)
recipe.trip_percent = 50.0
db.session.commit()
try:
plan_weights = recipe_total_weights_by_id(dispenser_id=E2E_DISP_ID, plan_date=today)
resp = self.client.get(
f"/api/recipes/{E2E_DISP_RECIPE_1}?date={today}",
headers={"X-Wesp-Kiosk": "1"},
)
data = resp.get_json()
self.assertEqual(data.get("total_weight"), plan_weights[E2E_DISP_RECIPE_1])
self.assertEqual(data.get("total_weight"), 10.0)
self.assertAlmostEqual(
sum(float(i.get("amount") or 0) for i in data.get("ingredients") or []),
10.0,
places=2,
)
finally:
recipe.trip_percent = 100.0
db.session.commit()
def test_weight_display_uses_daily_plan_weights(self) -> None:
from app.models import Component, Recipe
from app.services.daily_plan.replacements import replace_ingredient
alt = Component(
id="e2e-disp-comp-alt-loading",
name="Alt Silo",
type="silage",
dry_matter=35.0,
protein=3.0,
energy=6.0,
price=0.0,
)
db.session.add(alt)
recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1)
recipe.dry_matter_locked = True
db.session.commit()
plan_date = date.today().isoformat()
replace_ingredient(
E2E_DISP_RECIPE_1,
"e2e-disp-ing-1",
"e2e-disp-comp-alt-loading",
plan_date,
)
try:
kiosk = self.app.test_client()
plan_resp = kiosk.get(
f"/api/recipes/{E2E_DISP_RECIPE_1}?date={plan_date}",
headers={"X-Wesp-Kiosk": "1"},
)
self.assertEqual(plan_resp.status_code, 200)
plan_amount = float(plan_resp.get_json()["ingredients"][0]["amount"])
self.assertGreater(plan_amount, 10.0)
set_resp = kiosk.post(
"/api/set_current_recipe",
json={"recipe_id": E2E_DISP_RECIPE_1},
)
self.assertEqual(set_resp.status_code, 200)
display = kiosk.get("/api/weight_display_data")
self.assertEqual(display.status_code, 200, display.get_data(as_text=True))
body = display.get_json()
self.assertEqual(body["status"], "active")
self.assertEqual(body["total_component"], int(round(plan_amount)))
self.assertEqual(body["total_mixture"], int(round(plan_amount)))
self.assertIn("Alt Silo", body["component_name"])
finally:
recipe.dry_matter_locked = False
db.session.commit()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,75 @@
"""Unit-тесты daily_plan/skips.py и фильтрации builder."""
from __future__ import annotations
import unittest
from app import create_app, db
from app.services.daily_plan.builder import build_daily_plan
from app.services.daily_plan.skips import (
get_skipped_recipe_ids,
list_skips,
skip_trip,
unskip_trip,
)
from app.services.setup_state import mark_setup_complete
from tests.helpers.dispenser_recipe_fixtures import (
E2E_DISP_ID,
E2E_DISP_RECIPE_1,
E2E_PERIOD_A,
seed_dispenser_period_recipes,
)
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class DailyTripSkipServiceTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
seed_dispenser_period_recipes()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_skip_and_unskip_trip(self) -> None:
plan_date = "2026-06-07"
row = skip_trip(E2E_DISP_RECIPE_1, plan_date, user="zootech")
self.assertEqual(row.recipe_id, E2E_DISP_RECIPE_1)
self.assertEqual(row.plan_date.isoformat(), plan_date)
self.assertIn(E2E_DISP_RECIPE_1, get_skipped_recipe_ids(plan_date))
self.assertEqual(len(list_skips(plan_date)), 1)
self.assertTrue(unskip_trip(E2E_DISP_RECIPE_1, plan_date))
self.assertNotIn(E2E_DISP_RECIPE_1, get_skipped_recipe_ids(plan_date))
def test_skip_is_idempotent(self) -> None:
plan_date = "2026-06-08"
first = skip_trip(E2E_DISP_RECIPE_1, plan_date)
second = skip_trip(E2E_DISP_RECIPE_1, plan_date)
self.assertEqual(first.id, second.id)
self.assertEqual(len(list_skips(plan_date)), 1)
def test_builder_excludes_skipped_trips(self) -> None:
plan_date = "2026-06-09"
skip_trip(E2E_DISP_RECIPE_1, plan_date)
plan = build_daily_plan(dispenser_id=E2E_DISP_ID, plan_date=plan_date)
morning = next(p for p in plan["periods"] if p["id"] == E2E_PERIOD_A)
recipe_ids = {t["recipeId"] for t in morning["trips"]}
self.assertNotIn(E2E_DISP_RECIPE_1, recipe_ids)
skipped = plan["skippedTrips"]
self.assertEqual(len(skipped), 1)
self.assertEqual(skipped[0]["recipeId"], E2E_DISP_RECIPE_1)
totals = {row["name"]: row["totalKg"] for row in plan["ingredientTotals"]}
self.assertNotIn("Ing 1", totals)
def test_skip_unknown_recipe_raises(self) -> None:
with self.assertRaises(LookupError):
skip_trip("missing-recipe", "2026-06-07")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,13 @@
"""Backward compat: полный набор — tests/test_daily_plan_sync.py."""
from tests.test_daily_plan_sync import ( # noqa: F401
DailyPlanSyncApplyTests,
DailyPlanSyncClientOperatorTests,
DailyPlanSyncDualHarnessTests,
DailyPlanSyncRegistryTests,
)
if __name__ == "__main__":
import unittest
unittest.main()
+112
View File
@@ -0,0 +1,112 @@
import os
import tempfile
import unittest
from pathlib import Path
from app.services.factory_reset import (
FACTORY_RESET_CONFIRM_PHRASE,
assert_safe_factory_reset_target,
factory_reset_preview,
validate_factory_reset_request,
)
class FactoryResetSafetyTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.mkdtemp(prefix="wesp-factory-reset-")
self.base = Path(self._tmp)
self.data = self.base / "data"
self.data.mkdir(parents=True)
(self.data / "recipes.db").write_bytes(b"x" * 10)
def test_preview_lists_existing_files(self) -> None:
preview = factory_reset_preview(self.data, self.base)
self.assertTrue(preview["has_anything_to_reset"])
paths = {f["path"] for f in preview["files"]}
self.assertIn("recipes.db", paths)
self.assertEqual(preview["confirm_phrase"], FACTORY_RESET_CONFIRM_PHRASE)
def test_rejects_data_dir_outside_base(self) -> None:
other = Path(tempfile.mkdtemp(prefix="wesp-factory-reset-other-"))
with self.assertRaises(ValueError):
assert_safe_factory_reset_target(other / "data", self.base)
def test_rejects_non_data_directory_name(self) -> None:
wrong = self.base / "storage"
wrong.mkdir()
with self.assertRaises(ValueError):
assert_safe_factory_reset_target(wrong, self.base)
def test_validate_phrase(self) -> None:
ok, err, _, _ = validate_factory_reset_request(
{"confirmation_phrase": FACTORY_RESET_CONFIRM_PHRASE}
)
self.assertTrue(ok)
self.assertEqual(err, "")
bad, err2, _, _ = validate_factory_reset_request({"confirmation_phrase": "wrong"})
self.assertFalse(bad)
self.assertIn(FACTORY_RESET_CONFIRM_PHRASE, err2)
class FactoryResetAdminApiTests(unittest.TestCase):
def setUp(self) -> None:
from tests.test_admin_panel_access_and_users import AdminPanelTestConfig
from app import create_app, db
from app.models import WebUser
from werkzeug.security import generate_password_hash
self.app = create_app(AdminPanelTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
os.makedirs(AdminPanelTestConfig.DATA_DIR, exist_ok=True)
db.create_all()
db.session.add(
WebUser(
login="admin",
password_hash=generate_password_hash("admin-secret"),
is_superuser=True,
)
)
db.session.commit()
from app.services.setup_state import mark_setup_complete
mark_setup_complete(self.app)
login = self.client.post(
"/api/auth/login", json={"login": "admin", "password": "admin-secret"}
)
self.assertEqual(login.status_code, 200)
def tearDown(self) -> None:
from app import db
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_preview_requires_superuser_session(self) -> None:
from app.services.setup_state import mark_setup_complete
mark_setup_complete(self.app)
anon = self.app.test_client()
r = anon.get("/api/admin/factory-reset/preview")
self.assertIn(r.status_code, (401, 403))
def test_preview_ok(self) -> None:
r = self.client.get("/api/admin/factory-reset/preview")
self.assertEqual(r.status_code, 200)
body = r.get_json()
self.assertEqual(body.get("status"), "success")
self.assertIn("confirm_phrase", body)
def test_post_rejects_bad_phrase(self) -> None:
r = self.client.post(
"/api/admin/factory-reset",
json={"confirmation_phrase": "нет"},
)
self.assertEqual(r.status_code, 400)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,239 @@
"""Тесты API учётных документов и org_settings."""
from __future__ import annotations
import io
import os
import tempfile
import unittest
from datetime import datetime, timedelta
from pathlib import Path
from werkzeug.security import generate_password_hash
from app import create_app, db
from app.models import (
Component,
ComponentStock,
LoadingReport,
LoadingReportComponent,
OrgSettings,
WebUser,
)
from app.services.setup_state import mark_setup_complete
from config import TestingConfig
class FeedAccountingTestConfig(TestingConfig):
_TMP_DIR = tempfile.mkdtemp(prefix="wesp-fa-tests-")
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(_TMP_DIR, 'recipes_test.db')}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(_TMP_DIR, 'reports_test.db')}"}
AUTH_LOGIN = "fa-admin"
AUTH_PASSWORD = "fa-secret"
class FeedAccountingExportTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(FeedAccountingTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
db.session.add(
WebUser(
login="fa-admin",
password_hash=generate_password_hash("fa-secret"),
is_superuser=True,
)
)
db.session.commit()
mark_setup_complete(self.app)
login = self.client.post(
"/api/auth/login",
json={"login": "fa-admin", "password": "fa-secret"},
)
self.assertEqual(login.status_code, 200, login.get_data(as_text=True))
self._seed_data()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def _seed_data(self) -> None:
comp = Component(name="Сено", type="roughage")
db.session.add(comp)
db.session.flush()
stock = ComponentStock(
component_id=comp.id,
component_name=comp.name,
total_kg=1000.0,
stocktake_at=datetime.now(),
updated_at=datetime.now(),
)
db.session.add(stock)
report = LoadingReport(
recipe_id="r1",
recipe_name="Test",
start_time=datetime.now(),
dispenser_type="dispenser",
content_hash="abc",
)
db.session.add(report)
db.session.flush()
lrc = LoadingReportComponent(
report_id=report.id,
component_id=comp.id,
component_name=comp.name,
target_weight=10.0,
actual_weight=5.0,
loading_order=1,
content_hash="def",
)
db.session.add(lrc)
db.session.commit()
def test_org_settings_roundtrip(self) -> None:
put = self.client.put(
"/api/feed-accounting/org-settings",
json={"organization_name": "ООО Тест", "okpo": "12345678"},
)
self.assertEqual(put.status_code, 200)
get = self.client.get("/api/feed-accounting/org-settings")
self.assertEqual(get.status_code, 200)
body = get.get_json()
self.assertEqual(body["organization_name"], "ООО Тест")
self.assertEqual(body.get("okpo"), "12345678")
self.assertIn("available_farms", body)
self.assertIn("signatures", body)
row = db.session.get(OrgSettings, 1)
self.assertIsNotNone(row)
self.assertEqual(row.organization_name, "ООО Тест")
self.assertEqual(row.okpo, "12345678")
def test_sp20_preview_has_lines(self) -> None:
month = datetime.now().strftime("%Y-%m")
r = self.client.get(f"/api/feed-accounting/sp20-preview?month={month}")
self.assertEqual(r.status_code, 200)
body = r.get_json()
self.assertGreaterEqual(body.get("lines_count", 0), 1)
self.assertGreater(body.get("total_kg", 0), 0)
def test_signature_png_not_found(self) -> None:
from app.services.org_settings import get_signatures_dir
sig_dir = get_signatures_dir()
backup = None
target = sig_dir / "zootechnician.png"
if target.is_file():
backup = target.read_bytes()
target.unlink()
try:
r = self.client.get("/api/feed-accounting/signatures/zootechnician.png")
self.assertEqual(r.status_code, 404)
finally:
if backup is not None:
sig_dir.mkdir(parents=True, exist_ok=True)
target.write_bytes(backup)
def test_stock_balances_xlsx_requires_auth(self) -> None:
self.client.post("/api/auth/logout")
r = self.client.get("/api/feed-accounting/stock-balances.xlsx")
self.assertEqual(r.status_code, 401)
def test_stock_balances_xlsx_ok(self) -> None:
today = datetime.now().date()
start = today.replace(day=1).isoformat()
end = today.isoformat()
r = self.client.get(
f"/api/feed-accounting/stock-balances.xlsx?date_from={start}&date_to={end}"
)
self.assertEqual(r.status_code, 200)
self.assertIn("spreadsheetml", r.content_type)
self.assertGreater(len(r.data), 100)
def test_consumption_xlsx_requires_auth(self) -> None:
self.client.post("/api/auth/logout")
r = self.client.get("/api/feed-accounting/consumption.xlsx")
self.assertEqual(r.status_code, 401)
def test_consumption_xlsx_ok(self) -> None:
today = datetime.now().date()
start = today.replace(day=1).isoformat()
end = today.isoformat()
r = self.client.get(
f"/api/feed-accounting/consumption.xlsx?date_from={start}&date_to={end}"
)
self.assertEqual(r.status_code, 200)
self.assertIn("spreadsheetml", r.content_type)
cd = r.headers.get("Content-Disposition") or ""
self.assertIn("filename*=UTF-8", cd)
self.assertIn(f"Potreblenie_korma_{start}_{end}.xlsx", cd)
try:
from openpyxl import load_workbook
wb = load_workbook(io.BytesIO(r.data))
self.assertEqual(wb.sheetnames, ["Сводка", "Детализация", "Расход по дням"])
self.assertGreaterEqual(wb["Детализация"].max_row, 2)
except ImportError:
pass
def test_consumption_pdf_ok(self) -> None:
today = datetime.now().date()
start = today.replace(day=1).isoformat()
end = today.isoformat()
r = self.client.get(
f"/api/feed-accounting/consumption.pdf?date_from={start}&date_to={end}"
)
self.assertEqual(r.status_code, 200)
self.assertIn("pdf", r.content_type)
cd = r.headers.get("Content-Disposition") or ""
self.assertIn("filename*=UTF-8", cd)
self.assertIn(f"Potreblenie_korma_{start}_{end}.pdf", cd)
self.assertGreater(len(r.data), 100)
self.assertTrue(r.data.startswith(b"%PDF"))
def test_sp20_xlsx_ok(self) -> None:
month = datetime.now().strftime("%Y-%m")
r = self.client.get(f"/api/feed-accounting/sp20.xlsx?month={month}")
self.assertEqual(r.status_code, 200)
self.assertIn("spreadsheetml", r.content_type)
try:
from openpyxl import load_workbook
wb = load_workbook(io.BytesIO(r.data))
self.assertEqual(wb.sheetnames, ["стр1", "стр2"])
ws1 = wb["стр1"]
self.assertGreater(len(ws1.merged_cells.ranges), 50)
bordered = sum(
1
for row in ws1.iter_rows(max_row=31, max_col=29)
for c in row
if c.border and (c.border.left.style or c.border.top.style or c.border.bottom.style)
)
self.assertGreater(bordered, 100)
self.assertEqual(ws1.cell(row=6, column=27).value, "0325020")
except ImportError:
pass
def test_journal_xlsx_ok(self) -> None:
month = datetime.now().strftime("%Y-%m")
r = self.client.get(f"/api/feed-accounting/journal.xlsx?month={month}")
self.assertEqual(r.status_code, 200)
self.assertIn("spreadsheetml", r.content_type)
def test_documents_zip_ok(self) -> None:
month = datetime.now().strftime("%Y-%m")
today = datetime.now().date()
start = today.replace(day=1).isoformat()
end = today.isoformat()
r = self.client.get(
f"/api/feed-accounting/documents.zip?month={month}&date_from={start}&date_to={end}"
)
self.assertEqual(r.status_code, 200)
self.assertIn("zip", r.content_type)
self.assertGreater(len(r.data), 200)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,46 @@
"""Страница /feed_consumption — auth и HTML."""
from __future__ import annotations
import unittest
from app import create_app, db
from app.services.setup_state import mark_setup_complete
from tests.helpers.zootech_test_helpers import ZootechTestConfig, drain_response
class FeedConsumptionPageServedTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_feed_consumption_ok_after_login(self) -> None:
self.client.post(
"/api/auth/login",
json={"login": "zootech-test-admin", "password": "zootech-test-secret"},
)
resp = self.client.get("/feed_consumption")
self.assertEqual(resp.status_code, 200)
drain_response(resp)
def test_sklad_redirects_to_feed_consumption(self) -> None:
self.client.post(
"/api/auth/login",
json={"login": "zootech-test-admin", "password": "zootech-test-secret"},
)
resp = self.client.get("/sklad", follow_redirects=False)
self.assertIn(resp.status_code, (301, 302))
drain_response(resp)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,41 @@
"""UI-контракт /feed_consumption."""
from __future__ import annotations
import unittest
from tests.helpers.zootech_test_helpers import STATIC
class FeedConsumptionUiContractTests(unittest.TestCase):
def setUp(self) -> None:
self.html = (STATIC / "consumption.html").read_text(encoding="utf-8")
def test_fetches_sklad_api(self) -> None:
self.assertIn("fetch('/api/sklad'", self.html)
def test_feed_accounting_modal_script(self) -> None:
self.assertIn("feed-accounting-modal.js", self.html)
self.assertIn("faSaveAllBtn", self.html)
self.assertIn('data-tab="requisites"', self.html)
self.assertIn('data-tab="signatures"', self.html)
self.assertIn('data-tab="export"', self.html)
self.assertNotIn("save_faSigZootech", self.html)
def test_consumption_export_buttons(self) -> None:
self.assertNotIn('id="exportConsumptionXlsx"', self.html)
self.assertNotIn('id="exportConsumptionPdf"', self.html)
self.assertIn('id="faExportConsumptionXlsx"', self.html)
self.assertIn('id="faExportConsumptionPdf"', self.html)
def test_notify_sanitizes_technical_messages(self) -> None:
notify_js = (STATIC / "js" / "wesp-zootech-notify.js").read_text(encoding="utf-8")
self.assertIn("userFacingMessage", notify_js)
self.assertIn("isTechnicalMessage", notify_js)
modal_js = (STATIC / "js" / "feed-accounting-modal.js").read_text(encoding="utf-8")
self.assertNotIn("err.message", modal_js)
self.assertNotIn("r.status", modal_js)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,42 @@
"""Страница /feed_dispensers — auth и HTML."""
from __future__ import annotations
import unittest
from app import create_app, db
from app.services.setup_state import mark_setup_complete
from tests.helpers.zootech_test_helpers import ZootechTestConfig, drain_response
class FeedDispensersPageServedTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_feed_dispensers_redirects_without_session(self) -> None:
resp = self.client.get("/feed_dispensers", follow_redirects=False)
self.assertEqual(resp.status_code, 302)
drain_response(resp)
def test_feed_dispensers_ok_after_login(self) -> None:
self.client.post(
"/api/auth/login",
json={"login": "zootech-test-admin", "password": "zootech-test-secret"},
)
resp = self.client.get("/feed_dispensers")
self.assertEqual(resp.status_code, 200)
drain_response(resp)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,22 @@
"""UI-контракт /feed_dispensers."""
from __future__ import annotations
import unittest
from tests.helpers.zootech_test_helpers import STATIC
class FeedDispensersUiContractTests(unittest.TestCase):
def setUp(self) -> None:
self.html = (STATIC / "feed_dispensers.html").read_text(encoding="utf-8")
def test_lists_feed_dispensers_api(self) -> None:
self.assertIn("fetch('/api/feed_dispensers'", self.html)
def test_period_api_pattern_present(self) -> None:
self.assertIn("periods", self.html)
if __name__ == "__main__":
unittest.main()
+838
View File
@@ -0,0 +1,838 @@
"""API кормоцеха (FeedDispenser type=mill): без периодов, рецепты-сироты."""
from __future__ import annotations
import os
import tempfile
import unittest
import uuid
from datetime import datetime
from sqlalchemy import func, select
from app import create_app, db
from app.models import (
Component,
FeedDispenser,
FeedingPeriod,
Ingredient,
PeriodRecipe,
Recipe,
SyncQueue,
UnloadingGroup,
)
from config import TestingConfig
class FeedMillApiConfig(TestingConfig):
_TMP_DIR = tempfile.mkdtemp(prefix="wesp-feed-mill-api-")
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(_TMP_DIR, 'recipes_test.db')}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(_TMP_DIR, 'reports_test.db')}"}
AUTH_LOGIN = "mill-api-admin"
AUTH_PASSWORD = "mill-api-secret"
class FeedMillApiTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(FeedMillApiConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
login = self.client.post(
"/api/auth/login", json={"login": "mill-api-admin", "password": "mill-api-secret"}
)
self.assertEqual(login.status_code, 200)
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def _create_mill(self, *, name: str = "Кормоцех 1") -> str:
resp = self.client.post(
"/api/feed_dispensers",
json={
"name": name,
"farm": "Ферма А",
"operator": "Иванов",
"type": "mill",
},
)
self.assertEqual(resp.status_code, 201, resp.get_data(as_text=True))
return resp.get_json()["id"]
def _create_component(self, name: str = "Компонент А") -> str:
comp = Component(
id=str(uuid.uuid4()),
name=name,
type="grain",
dry_matter=50.0,
protein=0.0,
energy=0.0,
price=0.0,
)
db.session.add(comp)
db.session.commit()
return comp.id
def test_mill_crud_and_list_contract(self) -> None:
mill_id = self._create_mill()
get_one = self.client.get(f"/api/feed_dispensers/{mill_id}")
self.assertEqual(get_one.status_code, 200)
body = get_one.get_json()
self.assertEqual(body["type"], "mill")
self.assertEqual(body["name"], "Кормоцех 1")
self.assertEqual(body["periods"], [])
update = self.client.put(
f"/api/feed_dispensers/{mill_id}",
json={"name": "Кормоцех обновлён", "operator": "Петров"},
)
self.assertEqual(update.status_code, 200)
listed = self.client.get("/api/feed_dispensers?limit=50&offset=0")
self.assertEqual(listed.status_code, 200)
rows = listed.get_json()
mill_rows = [r for r in rows if r["id"] == mill_id]
self.assertEqual(len(mill_rows), 1)
self.assertEqual(mill_rows[0]["type"], "mill")
self.assertEqual(mill_rows[0]["name"], "Кормоцех обновлён")
delete = self.client.delete(f"/api/feed_dispensers/{mill_id}")
self.assertEqual(delete.status_code, 200)
gone = self.client.get(f"/api/feed_dispensers/{mill_id}")
self.assertEqual(gone.status_code, 404)
def test_mill_periods_empty_without_period_rows(self) -> None:
mill_id = self._create_mill()
periods = self.client.get(f"/api/feed_dispensers/{mill_id}/periods")
self.assertEqual(periods.status_code, 200)
self.assertEqual(periods.get_json(), [])
def test_mill_recipes_list_only_orphans(self) -> None:
mill_id = self._create_mill()
disp = FeedDispenser(
id="d-disp-mill-api",
name="Раздатчик",
farm="F",
operator="O",
type="dispenser",
)
period = FeedingPeriod(id="p-disp-mill-api", name="Утро", dispenser_id=disp.id)
r_orphan = Recipe(id="r-orphan-mill", name="Сирота", heads_per_trip=1, mixing_time=1)
r_linked = Recipe(id="r-linked-mill", name="В периоде", heads_per_trip=2, mixing_time=2)
db.session.add_all([disp, period, r_orphan, r_linked])
db.session.flush()
db.session.add(
PeriodRecipe(
period_id=period.id,
recipe_id=r_linked.id,
order=1,
created_at=datetime.now(),
created_by="system",
updated_by="system",
)
)
db.session.commit()
mill_recipes = self.client.get(f"/api/feed_dispensers/{mill_id}/recipes")
self.assertEqual(mill_recipes.status_code, 200)
ids = [x["id"] for x in mill_recipes.get_json()]
self.assertIn("r-orphan-mill", ids)
self.assertNotIn("r-linked-mill", ids)
disp_recipes = self.client.get("/api/feed_dispensers/d-disp-mill-api/recipes")
self.assertEqual(disp_recipes.status_code, 200)
disp_ids = [x["id"] for x in disp_recipes.get_json()]
self.assertEqual(disp_ids, ["r-linked-mill"])
def test_create_recipe_for_mill_without_period(self) -> None:
comp_id = self._create_component()
create = self.client.post(
"/api/recipes",
json={
"name": "Рецепт кормоцеха",
"headsPerTrip": 15,
"mixingTime": 8,
"tripPercent": 100,
"target_component_id": comp_id,
"ingredients": [
{
"component_id": comp_id,
"amount": 10,
"dry_matter": 50,
"weight_per_head": 1.0,
}
],
},
)
self.assertEqual(create.status_code, 201, create.get_data(as_text=True))
recipe_id = create.get_json()["id"]
get_recipe = self.client.get(f"/api/recipes/{recipe_id}")
self.assertEqual(get_recipe.status_code, 200)
body = get_recipe.get_json()
self.assertEqual(body["name"], "Рецепт кормоцеха")
self.assertEqual(body["target_component_id"], comp_id)
self.assertEqual(len(body.get("ingredients") or []), 1)
period_links = db.session.execute(
select(func.count())
.select_from(PeriodRecipe)
.where(PeriodRecipe.recipe_id == recipe_id, PeriodRecipe.is_deleted.is_(False))
).scalar()
self.assertEqual(period_links, 0)
def test_mill_recipe_visible_in_mill_list_after_create(self) -> None:
mill_id = self._create_mill()
comp_id = self._create_component("Зерно")
create = self.client.post(
"/api/recipes",
json={
"name": "Новый для цеха",
"headsPerTrip": 5,
"mixingTime": 3,
"target_component_id": comp_id,
"ingredients": [{"component_id": comp_id, "amount": 1}],
},
)
self.assertEqual(create.status_code, 201)
recipe_id = create.get_json()["id"]
listed = self.client.get(f"/api/feed_dispensers/{mill_id}/recipes")
self.assertEqual(listed.status_code, 200)
ids = [r["id"] for r in listed.get_json()]
self.assertIn(recipe_id, ids)
def test_mill_recipe_update_and_global_delete(self) -> None:
comp_id = self._create_component()
create = self.client.post(
"/api/recipes",
json={
"name": "До правки",
"headsPerTrip": 4,
"mixingTime": 2,
"target_component_id": comp_id,
"ingredients": [{"component_id": comp_id, "amount": 1}],
},
)
recipe_id = create.get_json()["id"]
update = self.client.put(
f"/api/recipes/{recipe_id}",
json={
"name": "После правки",
"heads_count": 6,
"mixing_time": 4,
"trip_percent": 100.0,
"target_component_id": comp_id,
"dry_matter_locked": False,
"unloading_link_broken": False,
"ingredients": [
{
"component_id": comp_id,
"weight_per_head": 1.0,
"amount": 2,
"dry_matter": 50.0,
"order": 1,
}
],
"unloading_groups": [
{
"name": "Г1",
"distribution_type": "percent",
"value": 100.0,
"weight": 2.0,
"order": 1,
}
],
},
)
self.assertEqual(update.status_code, 200)
self.assertTrue(update.get_json().get("success"))
get_after = self.client.get(f"/api/recipes/{recipe_id}")
self.assertEqual(get_after.get_json()["name"], "После правки")
recipe_update_task = db.session.execute(
select(SyncQueue).where(
SyncQueue.table_name == "recipe",
SyncQueue.record_id == recipe_id,
SyncQueue.action == "update",
)
).scalar_one_or_none()
self.assertIsNotNone(recipe_update_task)
delete = self.client.delete(f"/api/recipes/{recipe_id}")
self.assertEqual(delete.status_code, 200)
self.assertTrue(delete.get_json().get("success"))
gone = self.client.get(f"/api/recipes/{recipe_id}")
self.assertEqual(gone.status_code, 404)
def test_get_recipe_returns_unloading_link_broken_after_update(self) -> None:
comp_id = self._create_component()
create = self.client.post(
"/api/recipes",
json={
"name": "Связь выгрузки",
"headsPerTrip": 4,
"mixingTime": 2,
"target_component_id": comp_id,
"ingredients": [{"component_id": comp_id, "amount": 1}],
},
)
self.assertEqual(create.status_code, 201)
recipe_id = create.get_json()["id"]
update = self.client.put(
f"/api/recipes/{recipe_id}",
json={
"name": "Связь выгрузки",
"heads_count": 4,
"mixing_time": 2,
"trip_percent": 100.0,
"target_component_id": comp_id,
"dry_matter_locked": False,
"unloading_link_broken": True,
"ingredients": [
{
"component_id": comp_id,
"weight_per_head": 1.0,
"amount": 1,
"dry_matter": 50.0,
"order": 1,
}
],
"unloading_groups": [
{
"name": "Г1",
"distribution_type": "percent",
"value": 100.0,
"weight": 1.0,
"order": 1,
}
],
},
)
self.assertEqual(update.status_code, 200)
row = db.session.get(Recipe, recipe_id)
self.assertIsNotNone(row)
self.assertTrue(row.unloading_link_broken)
get_after = self.client.get(f"/api/recipes/{recipe_id}")
self.assertEqual(get_after.status_code, 200)
payload = get_after.get_json()
self.assertTrue(payload.get("unloading_link_broken"))
self.assertTrue(payload.get("unloadingLinkBroken"))
def test_get_recipe_serializes_unloading_groups_for_copy(self) -> None:
comp_id = self._create_component()
create = self.client.post(
"/api/recipes",
json={
"name": "Копия групп",
"headsPerTrip": 10,
"mixingTime": 2,
"target_component_id": comp_id,
"ingredients": [{"component_id": comp_id, "amount": 1}],
"unloadingGroups": [
{
"name": "Г1",
"distributionType": "heads",
"value": 5,
"weight": 100,
"order": 1,
}
],
},
)
self.assertEqual(create.status_code, 201)
recipe_id = create.get_json()["id"]
payload = self.client.get(f"/api/recipes/{recipe_id}").get_json()
self.assertIn("unloading_groups", payload)
self.assertEqual(len(payload["unloading_groups"]), 1)
grp = payload["unloading_groups"][0]
self.assertEqual(grp["distribution_type"], "heads")
self.assertEqual(grp["name"], "Г1")
self.assertIn("order", payload["ingredients"][0])
def test_detach_from_period_makes_recipe_visible_on_mill(self) -> None:
mill_id = self._create_mill()
disp = FeedDispenser(
id="d-detach", name="D", farm="F", operator="O", type="dispenser"
)
period = FeedingPeriod(id="p-detach", name="P", dispenser_id=disp.id)
recipe = Recipe(id="r-detach", name="R", heads_per_trip=1, mixing_time=1)
db.session.add_all([disp, period, recipe])
db.session.flush()
db.session.add(
PeriodRecipe(
period_id=period.id,
recipe_id=recipe.id,
order=1,
created_at=datetime.now(),
created_by="system",
updated_by="system",
)
)
db.session.commit()
before = self.client.get(f"/api/feed_dispensers/{mill_id}/recipes")
self.assertNotIn("r-detach", [x["id"] for x in before.get_json()])
detach = self.client.delete(
f"/api/feed_dispensers/d-detach/periods/p-detach/recipes/r-detach"
)
self.assertEqual(detach.status_code, 200)
after = self.client.get(f"/api/feed_dispensers/{mill_id}/recipes")
self.assertIn("r-detach", [x["id"] for x in after.get_json()])
def test_transfer_to_mill_rejected(self) -> None:
mill_id = self._create_mill()
disp = FeedDispenser(
id="d-from-xfer", name="From", farm="F", operator="O", type="dispenser"
)
p_from = FeedingPeriod(id="p-from-xfer", name="FromP", dispenser_id=disp.id)
p_to = FeedingPeriod(id="p-to-xfer", name="ToP", dispenser_id=mill_id)
recipe = Recipe(id="r-xfer-mill", name="Move", heads_per_trip=1, mixing_time=1)
db.session.add_all([disp, p_from, p_to, recipe])
db.session.flush()
db.session.add(
PeriodRecipe(
period_id=p_from.id,
recipe_id=recipe.id,
order=1,
created_at=datetime.now(),
created_by="system",
updated_by="system",
)
)
db.session.commit()
resp = self.client.post(
f"/api/feed_dispensers/{mill_id}/periods/p-to-xfer/recipes/r-xfer-mill/transfer",
json={
"from_dispenser_id": "d-from-xfer",
"from_period_id": "p-from-xfer",
"to_index": 0,
},
)
self.assertEqual(resp.status_code, 400)
self.assertIn("кормоцех", (resp.get_json().get("message") or "").lower())
def test_mill_recipe_update_deleted_ingredient_ids_syncs_delete(self) -> None:
comp1 = self._create_component("К1")
comp2 = self._create_component("К2")
create = self.client.post(
"/api/recipes",
json={
"name": "Два компонента",
"heads_count": 5,
"mixing_time": 3,
"trip_percent": 100.0,
"target_component_id": comp1,
"dry_matter_locked": False,
"unloading_link_broken": False,
"ingredients": [
{
"component_id": comp1,
"weight_per_head": 1.0,
"amount": 5,
"dry_matter": 50,
"order": 1,
},
{
"component_id": comp2,
"weight_per_head": 2.0,
"amount": 10,
"dry_matter": 60,
"order": 2,
},
],
"unloading_groups": [
{
"name": "Г1",
"distribution_type": "percent",
"value": 100.0,
"weight": 15.0,
"order": 1,
}
],
},
)
self.assertEqual(create.status_code, 201)
recipe_id = create.get_json()["id"]
ings = db.session.execute(
select(Ingredient).where(
Ingredient.recipe_id == recipe_id, Ingredient.is_deleted.is_(False)
)
).scalars().all()
self.assertEqual(len(ings), 2)
remove_id = sorted(ings, key=lambda x: x.order)[0].id
keep = sorted(ings, key=lambda x: x.order)[1]
update = self.client.put(
f"/api/recipes/{recipe_id}",
json={
"name": "Один компонент",
"heads_count": 5,
"mixing_time": 3,
"trip_percent": 100.0,
"target_component_id": comp2,
"dry_matter_locked": False,
"unloading_link_broken": False,
"deleted_ingredient_ids": [remove_id],
"ingredients": [
{
"id": keep.id,
"component_id": comp2,
"weight_per_head": 2.0,
"amount": 10,
"dry_matter": 60,
"order": 1,
}
],
"unloading_groups": [
{
"name": "Г1",
"distribution_type": "percent",
"value": 100.0,
"weight": 10.0,
"order": 1,
}
],
},
)
self.assertEqual(update.status_code, 200, update.get_data(as_text=True))
active = db.session.execute(
select(Ingredient).where(
Ingredient.recipe_id == recipe_id, Ingredient.is_deleted.is_(False)
)
).scalars().all()
self.assertEqual(len(active), 1)
self.assertEqual(active[0].id, keep.id)
delete_task = db.session.execute(
select(SyncQueue).where(
SyncQueue.table_name == "ingredient",
SyncQueue.record_id == remove_id,
SyncQueue.action == "delete",
)
).scalar_one_or_none()
self.assertIsNotNone(delete_task)
def test_mill_recipe_update_change_component_syncs_ingredient_update(self) -> None:
comp1 = self._create_component("К1")
comp2 = self._create_component("К2")
create = self.client.post(
"/api/recipes",
json={
"name": "Смена компонента",
"heads_count": 5,
"mixing_time": 3,
"trip_percent": 100.0,
"target_component_id": comp1,
"dry_matter_locked": False,
"unloading_link_broken": False,
"ingredients": [
{
"component_id": comp1,
"weight_per_head": 1.0,
"amount": 5,
"dry_matter": 50,
"order": 1,
}
],
"unloading_groups": [],
},
)
self.assertEqual(create.status_code, 201)
recipe_id = create.get_json()["id"]
ing = db.session.execute(
select(Ingredient).where(
Ingredient.recipe_id == recipe_id, Ingredient.is_deleted.is_(False)
)
).scalar_one()
update = self.client.put(
f"/api/recipes/{recipe_id}",
json={
"name": "Смена компонента",
"heads_count": 5,
"mixing_time": 3,
"trip_percent": 100.0,
"target_component_id": comp2,
"dry_matter_locked": False,
"unloading_link_broken": False,
"ingredients": [
{
"id": ing.id,
"component_id": comp2,
"weight_per_head": 1.0,
"amount": 5,
"dry_matter": 50,
"order": 1,
}
],
"unloading_groups": [],
},
)
self.assertEqual(update.status_code, 200)
self.assertEqual(str(db.session.get(Ingredient, ing.id).component_id), comp2)
self.assertIsNotNone(
db.session.execute(
select(SyncQueue).where(
SyncQueue.table_name == "ingredient",
SyncQueue.record_id == ing.id,
SyncQueue.action == "update",
)
).scalar_one_or_none()
)
def test_mill_recipe_update_omit_ingredient_syncs_delete(self) -> None:
comp1 = self._create_component("К1")
comp2 = self._create_component("К2")
create = self.client.post(
"/api/recipes",
json={
"name": "Два",
"heads_count": 5,
"mixing_time": 3,
"trip_percent": 100.0,
"target_component_id": comp1,
"dry_matter_locked": False,
"unloading_link_broken": False,
"ingredients": [
{"component_id": comp1, "weight_per_head": 1.0, "amount": 5, "dry_matter": 50, "order": 1},
{"component_id": comp2, "weight_per_head": 2.0, "amount": 10, "dry_matter": 60, "order": 2},
],
"unloading_groups": [],
},
)
recipe_id = create.get_json()["id"]
ings = sorted(
db.session.execute(
select(Ingredient).where(
Ingredient.recipe_id == recipe_id, Ingredient.is_deleted.is_(False)
)
).scalars().all(),
key=lambda x: x.order,
)
keep = ings[1]
update = self.client.put(
f"/api/recipes/{recipe_id}",
json={
"name": "Один",
"heads_count": 5,
"mixing_time": 3,
"trip_percent": 100.0,
"target_component_id": comp2,
"dry_matter_locked": False,
"unloading_link_broken": False,
"ingredients": [
{
"id": keep.id,
"component_id": comp2,
"weight_per_head": 2.0,
"amount": 10,
"dry_matter": 60,
"order": 1,
}
],
"unloading_groups": [],
},
)
self.assertEqual(update.status_code, 200)
self.assertTrue(db.session.get(Ingredient, ings[0].id).is_deleted)
self.assertIsNotNone(
db.session.execute(
select(SyncQueue).where(
SyncQueue.table_name == "ingredient",
SyncQueue.record_id == ings[0].id,
SyncQueue.action == "delete",
)
).scalar_one_or_none()
)
def test_mill_recipe_update_deleted_group_ids_syncs_delete(self) -> None:
comp = self._create_component()
create = self.client.post(
"/api/recipes",
json={
"name": "Группы",
"heads_count": 5,
"mixing_time": 3,
"trip_percent": 100.0,
"target_component_id": comp,
"dry_matter_locked": False,
"unloading_link_broken": False,
"ingredients": [
{"component_id": comp, "weight_per_head": 1.0, "amount": 5, "dry_matter": 50, "order": 1},
],
"unloading_groups": [
{"name": "Г1", "distribution_type": "percent", "value": 50.0, "weight": 5.0, "order": 1},
{"name": "Г2", "distribution_type": "percent", "value": 50.0, "weight": 5.0, "order": 2},
],
},
)
recipe_id = create.get_json()["id"]
groups = sorted(
db.session.execute(
select(UnloadingGroup).where(
UnloadingGroup.recipe_id == recipe_id, UnloadingGroup.is_deleted.is_(False)
)
).scalars().all(),
key=lambda g: g.order,
)
remove_id, keep = groups[0].id, groups[1]
ing = db.session.execute(
select(Ingredient).where(
Ingredient.recipe_id == recipe_id, Ingredient.is_deleted.is_(False)
)
).scalar_one()
update = self.client.put(
f"/api/recipes/{recipe_id}",
json={
"name": "Группы",
"heads_count": 5,
"mixing_time": 3,
"trip_percent": 100.0,
"target_component_id": comp,
"dry_matter_locked": False,
"unloading_link_broken": False,
"deleted_unloading_group_ids": [remove_id],
"ingredients": [
{
"id": ing.id,
"component_id": comp,
"weight_per_head": 1.0,
"amount": 5,
"dry_matter": 50,
"order": 1,
}
],
"unloading_groups": [
{
"id": keep.id,
"name": keep.name,
"distribution_type": keep.distribution_type,
"value": keep.value,
"weight": keep.weight,
"order": 1,
}
],
},
)
self.assertEqual(update.status_code, 200)
self.assertTrue(db.session.get(UnloadingGroup, remove_id).is_deleted)
self.assertIsNotNone(
db.session.execute(
select(SyncQueue).where(
SyncQueue.table_name == "unloading_group",
SyncQueue.record_id == remove_id,
SyncQueue.action == "delete",
)
).scalar_one_or_none()
)
def test_mill_recipe_delete_cascades_children_sync(self) -> None:
comp = self._create_component()
create = self.client.post(
"/api/recipes",
json={
"name": "Каскад",
"heads_count": 3,
"mixing_time": 2,
"trip_percent": 100.0,
"target_component_id": comp,
"dry_matter_locked": False,
"unloading_link_broken": False,
"ingredients": [
{"component_id": comp, "weight_per_head": 1.0, "amount": 3, "dry_matter": 50, "order": 1},
],
"unloading_groups": [
{"name": "Г1", "distribution_type": "percent", "value": 100.0, "weight": 3.0, "order": 1},
],
},
)
recipe_id = create.get_json()["id"]
ing = db.session.execute(
select(Ingredient).where(Ingredient.recipe_id == recipe_id)
).scalar_one()
grp = db.session.execute(
select(UnloadingGroup).where(UnloadingGroup.recipe_id == recipe_id)
).scalar_one()
delete = self.client.delete(f"/api/recipes/{recipe_id}")
self.assertEqual(delete.status_code, 200)
self.assertTrue(db.session.get(Recipe, recipe_id).is_deleted)
self.assertTrue(db.session.get(Ingredient, ing.id).is_deleted)
self.assertTrue(db.session.get(UnloadingGroup, grp.id).is_deleted)
self.assertIsNotNone(
db.session.execute(
select(SyncQueue).where(
SyncQueue.table_name == "recipe",
SyncQueue.record_id == recipe_id,
SyncQueue.action == "delete",
)
).scalar_one_or_none()
)
self.assertGreaterEqual(
db.session.scalar(
select(func.count()).select_from(SyncQueue).where(
SyncQueue.table_name == "ingredient",
SyncQueue.record_id == ing.id,
SyncQueue.action == "update",
)
),
1,
)
def test_recipe_create_enqueues_sync_without_period_recipes(self) -> None:
comp_id = self._create_component()
before = db.session.scalar(select(func.count()).select_from(SyncQueue)) or 0
create = self.client.post(
"/api/recipes",
json={
"name": "Sync mill recipe",
"headsPerTrip": 3,
"mixingTime": 1,
"ingredients": [{"component_id": comp_id, "amount": 1}],
},
)
self.assertEqual(create.status_code, 201)
recipe_id = create.get_json()["id"]
after = db.session.scalar(select(func.count()).select_from(SyncQueue)) or 0
self.assertGreater(after, before)
recipe_tasks = db.session.execute(
select(SyncQueue).where(
SyncQueue.table_name == "recipe",
SyncQueue.record_id == recipe_id,
)
).scalars().all()
self.assertTrue(recipe_tasks)
period_tasks = db.session.execute(
select(SyncQueue).where(
SyncQueue.table_name == "period_recipes",
SyncQueue.record_id.like(f"%{recipe_id}%"),
)
).scalars().all()
self.assertEqual(period_tasks, [])
if __name__ == "__main__":
unittest.main()
+384
View File
@@ -0,0 +1,384 @@
"""Sync кормоцеха: feed_dispenser type=mill и рецепты без period_recipes."""
from __future__ import annotations
import os
import tempfile
import unittest
import uuid
from datetime import datetime
from sqlalchemy import select
from app import create_app, db
from app.models import (
Component,
FeedDispenser,
Ingredient,
Recipe,
SyncClient,
SyncEngineState,
SyncQueue,
WESP_SUPPRESS_SYNC_ENQUEUE,
)
from app.services.recipe_update_service import update_recipe_from_payload
from app.services.sync_manager import SyncManager, apply_sync_change, enqueue_sync_queue_task
from app.services.sync_record_data import get_record_data_for_sync
from config import TestingConfig
from tests.helpers.mill_recipe_fixtures import (
create_recipe_with_children,
ingredient_payload_from_row,
recipe_update_payload,
)
class FeedMillSyncConfig(TestingConfig):
_TMP_DIR = tempfile.mkdtemp(prefix="wesp-feed-mill-sync-")
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(_TMP_DIR, 'recipes_test.db')}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(_TMP_DIR, 'reports_test.db')}"}
AUTH_LOGIN = "mill-sync-admin"
AUTH_PASSWORD = "mill-sync-secret"
class FeedMillSyncTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(FeedMillSyncConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
self.node_id = "mill-sync-client-1"
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def _register_and_bootstrap(self) -> None:
resp = self.client.post(
"/api/sync/register",
json={"client_id": self.node_id, "client_name": "Mill sync client"},
)
self.assertEqual(resp.status_code, 200)
state = db.session.get(SyncEngineState, 1)
if state is None:
state = SyncEngineState(id=1)
db.session.add(state)
state.universal_bootstrap_completed_at = datetime.now()
state.universal_bootstrap_cursor = 999
sc = db.session.execute(
select(SyncClient).where(SyncClient.node_id == self.node_id)
).scalar_one()
sc.personal_snapshot_cursor = 999
sc.personal_snapshot_completed_at = datetime.now()
db.session.commit()
def test_apply_sync_feed_dispenser_mill_type(self) -> None:
mill_id = str(uuid.uuid4())
data = {
"id": mill_id,
"name": "Цех sync",
"farm": "Ферма",
"operator": "Оператор",
"type": "mill",
"is_active": True,
"version": 1,
"content_hash": "",
"created_by": "system",
"updated_by": "system",
"is_deleted": False,
}
res = apply_sync_change("feed_dispenser", mill_id, "create", data)
self.assertTrue(res.get("success"), msg=res.get("error"))
db.session.commit()
row = db.session.get(FeedDispenser, mill_id)
self.assertIsNotNone(row)
self.assertEqual(row.type, "mill")
def test_sync_payload_includes_mill_type_and_target_component(self) -> None:
comp_id = str(uuid.uuid4())
mill_id = str(uuid.uuid4())
recipe_id = str(uuid.uuid4())
db.session.add_all(
[
Component(
id=comp_id,
name="Компонент sync",
type="grain",
dry_matter=40.0,
protein=0.0,
energy=0.0,
price=0.0,
),
FeedDispenser(
id=mill_id,
name="Цех",
farm="F",
operator="O",
type="mill",
content_hash="",
),
Recipe(
id=recipe_id,
name="Рецепт sync",
heads_per_trip=7,
mixing_time=5,
trip_percent=100.0,
target_component_id=comp_id,
content_hash="",
),
]
)
db.session.commit()
mill_data = get_record_data_for_sync("feed_dispenser", mill_id)
recipe_data = get_record_data_for_sync("recipe", recipe_id)
self.assertIsNotNone(mill_data)
self.assertIsNotNone(recipe_data)
self.assertEqual(mill_data.get("type"), "mill")
self.assertEqual(recipe_data.get("target_component_id"), comp_id)
def test_pull_delivers_mill_dispenser_to_client(self) -> None:
self._register_and_bootstrap()
mill_id = str(uuid.uuid4())
db.session.add(
FeedDispenser(
id=mill_id,
name="Pull mill",
farm="F",
operator="O",
type="mill",
content_hash="",
)
)
enqueue_sync_queue_task(
"feed_dispenser", mill_id, "create", priority=3, target_node_id=None
)
db.session.commit()
res = SyncManager.process_pull(client_id=self.node_id, limit=20)
self.assertEqual(res["status_code"], 200)
changes = res["payload"].get("changes") or []
mill_changes = [
c for c in changes if c.get("table_name") == "feed_dispenser" and c.get("record_id") == mill_id
]
self.assertTrue(mill_changes)
self.assertEqual(mill_changes[0]["data"].get("type"), "mill")
def test_pull_delivers_orphan_recipe_without_period_recipes(self) -> None:
self._register_and_bootstrap()
recipe_id = str(uuid.uuid4())
db.session.add(
Recipe(
id=recipe_id,
name="Orphan pull",
heads_per_trip=3,
mixing_time=2,
trip_percent=100.0,
content_hash="",
)
)
enqueue_sync_queue_task("recipe", recipe_id, "create", priority=2, target_node_id=None)
db.session.commit()
res = SyncManager.process_pull(client_id=self.node_id, limit=50)
self.assertEqual(res["status_code"], 200)
changes = res["payload"].get("changes") or []
recipe_changes = [
c for c in changes if c.get("table_name") == "recipe" and c.get("record_id") == recipe_id
]
self.assertTrue(recipe_changes)
period_changes = [c for c in changes if c.get("table_name") == "period_recipes"]
self.assertEqual(period_changes, [])
def test_push_orphan_recipe_create_roundtrip(self) -> None:
self._register_and_bootstrap()
recipe_id = str(uuid.uuid4())
comp_id = str(uuid.uuid4())
db.session.add(
Component(
id=comp_id,
name="C",
type="grain",
dry_matter=1.0,
protein=0.0,
energy=0.0,
price=0.0,
)
)
db.session.commit()
payload = {
"id": recipe_id,
"name": "Mill push recipe",
"heads_per_trip": 9,
"mixing_time": 4,
"trip_percent": 100.0,
"target_component_id": comp_id,
"dry_matter_locked": False,
"unloading_link_broken": False,
"version": 1,
"content_hash": "",
"created_by": "system",
"updated_by": "system",
"is_deleted": False,
"ingredients": [],
"unloading_groups": [],
}
push = self.client.post(
"/api/sync/push",
json={
"client_id": self.node_id,
"changes": [
{
"table_name": "recipe",
"record_id": recipe_id,
"action": "create",
"data": payload,
}
],
},
)
self.assertEqual(push.status_code, 200)
self.assertTrue(push.get_json().get("success"))
saved = db.session.get(Recipe, recipe_id)
self.assertIsNotNone(saved)
self.assertEqual(saved.name, "Mill push recipe")
self.assertEqual(saved.target_component_id, comp_id)
def test_pull_recipe_update_after_ingredient_delete(self) -> None:
self._register_and_bootstrap()
recipe_id, ing_ids, _ = create_recipe_with_children(with_groups=False)
recipe = db.session.get(Recipe, recipe_id)
remove_id, keep_id = ing_ids[0], ing_ids[1]
keep_row = db.session.get(Ingredient, keep_id)
update_recipe_from_payload(
recipe_id,
recipe_update_payload(
recipe,
deleted_ingredient_ids=[remove_id],
ingredients=[ingredient_payload_from_row(keep_row, order=1)],
groups=[],
),
)
db.session.commit()
res = SyncManager.process_pull(client_id=self.node_id, limit=50)
self.assertEqual(res["status_code"], 200)
changes = res["payload"].get("changes") or []
ing_delete = [
c
for c in changes
if c.get("table_name") == "ingredient"
and c.get("record_id") == remove_id
and c.get("action") == "delete"
]
recipe_update = [
c
for c in changes
if c.get("table_name") == "recipe"
and c.get("record_id") == recipe_id
and c.get("action") == "update"
]
self.assertTrue(ing_delete, changes)
self.assertTrue(recipe_update, changes)
deleted_data = get_record_data_for_sync("ingredient", remove_id)
self.assertIsNotNone(deleted_data)
self.assertTrue(deleted_data.get("is_deleted"))
def test_apply_ingredient_delete_on_client_db(self) -> None:
recipe_id, ing_ids, _ = create_recipe_with_children(with_groups=False)
remove_id = ing_ids[0]
data = get_record_data_for_sync("ingredient", remove_id)
self.assertIsNotNone(data)
data = dict(data)
data["is_deleted"] = True
db.session.info[WESP_SUPPRESS_SYNC_ENQUEUE] = True
try:
res = apply_sync_change("ingredient", remove_id, "delete", data)
self.assertTrue(res.get("success"), res.get("error"))
db.session.commit()
finally:
db.session.info.pop(WESP_SUPPRESS_SYNC_ENQUEUE, None)
active = db.session.execute(
select(Ingredient).where(
Ingredient.recipe_id == recipe_id, Ingredient.is_deleted.is_(False)
)
).scalars().all()
self.assertEqual(len(active), 1)
self.assertTrue(db.session.get(Ingredient, remove_id).is_deleted)
def test_pull_recipe_with_nested_ingredients_after_update(self) -> None:
self._register_and_bootstrap()
recipe_id, ing_ids, _ = create_recipe_with_children(with_groups=False)
recipe = db.session.get(Recipe, recipe_id)
keep = db.session.get(Ingredient, ing_ids[1])
update_recipe_from_payload(
recipe_id,
recipe_update_payload(
recipe,
deleted_ingredient_ids=[ing_ids[0]],
ingredients=[ingredient_payload_from_row(keep, order=1)],
groups=[],
),
)
db.session.commit()
keep_data = get_record_data_for_sync("ingredient", str(keep.id))
self.assertIsNotNone(keep_data)
self.assertFalse(keep_data.get("is_deleted"))
self.assertEqual(str(keep_data.get("component_id")), str(keep.component_id))
res = SyncManager.process_pull(client_id=self.node_id, limit=50)
changes = res["payload"].get("changes") or []
recipe_changes = [c for c in changes if c.get("table_name") == "recipe" and c.get("record_id") == recipe_id]
ing_updates = [
c
for c in changes
if c.get("table_name") == "ingredient" and c.get("record_id") == str(keep.id)
]
self.assertTrue(recipe_changes)
self.assertTrue(ing_updates)
def test_mill_recipe_delete_enqueued_on_api_delete(self) -> None:
self._register_and_bootstrap()
login = self.client.post(
"/api/auth/login",
json={"login": "mill-sync-admin", "password": "mill-sync-secret"},
)
self.assertEqual(login.status_code, 200)
recipe_id = str(uuid.uuid4())
db.session.add(
Recipe(
id=recipe_id,
name="To delete",
heads_per_trip=1,
mixing_time=1,
content_hash="",
)
)
db.session.commit()
delete = self.client.delete(f"/api/recipes/{recipe_id}")
self.assertEqual(delete.status_code, 200)
task = db.session.execute(
select(SyncQueue).where(
SyncQueue.table_name == "recipe",
SyncQueue.record_id == recipe_id,
SyncQueue.action == "delete",
)
).scalar_one_or_none()
self.assertIsNotNone(task)
if __name__ == "__main__":
unittest.main()
+125
View File
@@ -0,0 +1,125 @@
"""UI-контракты кормоцеха: HTML/JS и страницы (без браузерного раннера)."""
from __future__ import annotations
import os
import re
import tempfile
import unittest
from pathlib import Path
from app import create_app, db
from app.services.setup_state import mark_setup_complete
from config import TestingConfig
PROJECT_ROOT = Path(__file__).resolve().parent.parent
STATIC = PROJECT_ROOT / "static"
class FeedMillUiConfig(TestingConfig):
_TMP_DIR = tempfile.mkdtemp(prefix="wesp-feed-mill-ui-")
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(_TMP_DIR, 'recipes_test.db')}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(_TMP_DIR, 'reports_test.db')}"}
AUTH_LOGIN = "mill-ui-admin"
AUTH_PASSWORD = "mill-ui-secret"
def _drain_response(resp) -> None:
try:
resp.get_data()
finally:
close = getattr(resp, "close", None)
if callable(close):
close()
class FeedMillUiTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(FeedMillUiConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def _login(self) -> None:
resp = self.client.post(
"/api/auth/login", json={"login": "mill-ui-admin", "password": "mill-ui-secret"}
)
self.assertEqual(resp.status_code, 200)
def test_feed_dispensers_page_requires_auth(self) -> None:
resp = self.client.get("/feed_dispensers", follow_redirects=False)
self.assertIn(resp.status_code, (302, 401, 403))
def test_feed_dispensers_and_recipes_pages_ok_when_authed(self) -> None:
self._login()
for path in ("/feed_dispensers", "/recipes"):
resp = self.client.get(path)
_drain_response(resp)
self.assertEqual(resp.status_code, 200, path)
self.assertIn(b"<!DOCTYPE html>", resp.data or b"")
def test_feed_dispensers_html_mill_modal_and_create_payload(self) -> None:
html = (STATIC / "feed_dispensers.html").read_text(encoding="utf-8")
self.assertIn("Добавить кормоцех", html)
self.assertIn('id="addMillModalLabel"', html)
self.assertIn("type: 'mill'", html)
self.assertIn("dispenser-card--mill", html)
self.assertIn("!isMill", html)
self.assertIn("managePeriods", html)
def test_recipes_data_controller_mill_branch(self) -> None:
js = (STATIC / "js" / "pages" / "recipes-data-controller.js").read_text(encoding="utf-8")
self.assertIn('getCurrentDispenserType() === "mill"', js)
self.assertIn("loadMillRecipes", js)
self.assertIn('millRecipesList', js)
self.assertIn('dispenser.type === "mill"', js)
self.assertIn('? "recipes" : "periods"', js)
def test_recipes_operations_mill_uses_global_delete(self) -> None:
js = (STATIC / "js" / "pages" / "recipes-operations-controller.js").read_text(
encoding="utf-8"
)
self.assertIn('getCurrentDispenserType() === "mill"', js)
self.assertIn("unlinkFromPeriodOnly", js)
self.assertIn("/api/recipes/", js)
# mill: DELETE глобальный, не unlink из периода
self.assertRegex(
js,
r"unlinkFromPeriodOnly\s*=\s*Boolean\(periodId\s*&&\s*dispenserId\s*&&\s*!isMill\)",
)
def test_recipes_editor_mill_target_component(self) -> None:
js = (STATIC / "js" / "pages" / "recipes-editor-controller.js").read_text(encoding="utf-8")
self.assertIn('getCurrentDispenserType() === "mill"', js)
self.assertIn("target_component", js)
def test_recipe_period_transfer_excludes_mill(self) -> None:
js = (STATIC / "js" / "modules" / "recipes" / "recipe-period-transfer.js").read_text(
encoding="utf-8"
)
self.assertIn('dataset.dispenserType === "mill"', js)
mill_skips = len(re.findall(r'dispenserType\s*===\s*["\']mill["\']', js))
self.assertGreaterEqual(mill_skips, 2)
def test_recipes_html_legacy_mill_flow_present(self) -> None:
html = (STATIC / "recipes.html").read_text(encoding="utf-8")
self.assertIn("currentDispenserType = 'dispenser'", html)
self.assertIn("currentDispenserType === 'mill'", html)
self.assertIn("target_component_id", html)
self.assertIn("Загрузка рецептов кормоцеха", html)
def test_recipe_skeleton_mill_label(self) -> None:
js = (STATIC / "js" / "modules" / "recipes" / "recipe-skeleton.js").read_text(encoding="utf-8")
self.assertIn("mill:", js)
self.assertIn("Загрузка рецептов", js)
if __name__ == "__main__":
unittest.main()
+115
View File
@@ -0,0 +1,115 @@
"""API /api/feed-quality."""
from __future__ import annotations
import unittest
from datetime import datetime
from app import create_app, db
from app.models import ComponentLoadingTime, LoadingReport, LoadingReportComponent, Recipe
from app.services.feed_quality.evaluator import evaluate_loading_report
from app.services.setup_state import mark_setup_complete
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class FeedQualityApiTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
self.client.post(
"/api/auth/login",
json={"login": "zootech-test-admin", "password": "zootech-test-secret"},
)
db.session.add(
Recipe(id="api-r1", name="API рейс", heads_per_trip=1, content_hash="")
)
report = LoadingReport(
id="api-lr-1",
recipe_id="api-r1",
recipe_name="API рейс",
start_time=datetime.now(),
target_mixing_time=60,
actual_mixing_time=60,
total_weight=50.0,
created_by="system",
updated_by="system",
)
db.session.add(report)
db.session.flush()
db.session.add(
LoadingReportComponent(
report_id=report.id,
component_name="C1",
target_weight=50.0,
actual_weight=60.0,
overload=10.0,
loading_order=1,
created_by="system",
updated_by="system",
)
)
db.session.add(
ComponentLoadingTime(
report_id=report.id,
component_name="C1",
start_time=datetime.now(),
end_time=datetime.now(),
loading_duration=42.5,
loading_order=1,
created_by="system",
updated_by="system",
)
)
db.session.commit()
evaluate_loading_report(report.id, send_notifications=False)
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_list_alerts(self) -> None:
today = datetime.now().strftime("%Y-%m-%d")
resp = self.client.get(f"/api/feed-quality/alerts?date_from={today}&date_to={today}")
self.assertEqual(resp.status_code, 200)
body = resp.get_json()
self.assertGreaterEqual(body["total"], 1)
self.assertIn("bySeverity", body)
self.assertEqual(body["items"][0]["linkKind"], "report_loading")
component_alert = next(
(x for x in body["items"] if x.get("componentName") == "C1"),
body["items"][0],
)
self.assertAlmostEqual(float(component_alert["actualKg"]), 60.0, places=1)
self.assertAlmostEqual(float(component_alert["durationSec"]), 42.5, places=1)
def test_summary(self) -> None:
today = datetime.now().strftime("%Y-%m-%d")
resp = self.client.get(f"/api/feed-quality/alerts/summary?date_from={today}&date_to={today}")
self.assertEqual(resp.status_code, 200)
self.assertGreaterEqual(resp.get_json()["total"], 1)
def test_requires_auth(self) -> None:
self.client.post("/api/auth/logout")
resp = self.client.get("/api/feed-quality/alerts")
self.assertEqual(resp.status_code, 401)
def test_backfill_on_list_for_unevaluated_report(self) -> None:
from sqlalchemy import delete
from app.models.feed_alert import FeedAlert
db.session.execute(delete(FeedAlert))
db.session.commit()
today = datetime.now().strftime("%Y-%m-%d")
resp = self.client.get(f"/api/feed-quality/alerts?date_from={today}&date_to={today}")
self.assertEqual(resp.status_code, 200)
self.assertGreaterEqual(resp.get_json()["total"], 1)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,279 @@
"""Интеграционные тесты feed_quality.evaluator."""
from __future__ import annotations
import unittest
from datetime import datetime
from sqlalchemy import select
from app import create_app, db
from app.models import (
ComponentLoadingTime,
LoadingReport,
LoadingReportComponent,
Recipe,
UnloadingReport,
UnloadingReportGroup,
)
from app.models.feed_alert import FeedAlert
from app.services.feed_quality.evaluator import evaluate_loading_report, evaluate_unloading_report
from app.services.feed_quality.rules import (
EVENT_LEFT_IN_MIXER,
EVENT_LOADING_FAST,
EVENT_LOADING_TIME,
EVENT_OVERLOAD,
EVENT_UNDERLOAD,
)
from app.services.feed_quality.settings_store import save_feed_quality_settings
from app.services.setup_state import mark_setup_complete
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class FeedQualityEvaluatorTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
db.session.add(
Recipe(
id="fq-r1",
name="Тестовый рейс",
heads_per_trip=10,
mixing_time=5,
content_hash="",
)
)
db.session.commit()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def _loading_with_overload(self) -> str:
report = LoadingReport(
id="fq-lr-1",
recipe_id="fq-r1",
recipe_name="Тестовый рейс",
start_time=datetime.now(),
target_mixing_time=300,
actual_mixing_time=350,
total_weight=100.0,
created_by="system",
updated_by="system",
)
db.session.add(report)
db.session.flush()
db.session.add(
LoadingReportComponent(
report_id=report.id,
component_name="Силос",
target_weight=100.0,
actual_weight=115.0,
overload=15.0,
loading_order=1,
created_by="system",
updated_by="system",
)
)
db.session.commit()
return report.id
def test_evaluate_loading_creates_alerts(self) -> None:
report_id = self._loading_with_overload()
rows = evaluate_loading_report(report_id, send_notifications=False)
self.assertGreaterEqual(len(rows), 2)
types = {r.event_type for r in rows}
self.assertIn(EVENT_OVERLOAD, types)
def test_idempotent_re_evaluate(self) -> None:
report_id = self._loading_with_overload()
first = evaluate_loading_report(report_id, send_notifications=False)
evaluate_loading_report(report_id, send_notifications=False)
second_count = len(
db.session.execute(
select(FeedAlert).where(FeedAlert.loading_report_id == report_id)
)
.scalars()
.all()
)
self.assertEqual(second_count, len(first))
def test_fast_loading_merged_with_underload(self) -> None:
report = LoadingReport(
id="fq-lr-fast",
recipe_id="fq-r1",
recipe_name="Тестовый рейс",
start_time=datetime.now(),
target_mixing_time=60,
actual_mixing_time=60,
total_weight=62.0,
created_by="system",
updated_by="system",
)
db.session.add(report)
db.session.flush()
db.session.add(
LoadingReportComponent(
report_id=report.id,
component_name="Сено",
target_weight=62.0,
actual_weight=0.0,
overload=-62.0,
loading_order=1,
created_by="system",
updated_by="system",
)
)
now = datetime.now()
db.session.add(
ComponentLoadingTime(
report_id=report.id,
component_name="Сено",
start_time=now,
end_time=now,
loading_duration=0.9,
loading_order=1,
created_by="system",
updated_by="system",
)
)
db.session.commit()
rows = evaluate_loading_report(report.id, send_notifications=False)
component_rows = [r for r in rows if r.component_name == "Сено"]
self.assertEqual(len(component_rows), 1)
self.assertEqual(component_rows[0].event_type, EVENT_UNDERLOAD)
self.assertIn("0.9 с", component_rows[0].detail)
def test_fast_loading_separate_when_weight_ok(self) -> None:
report = LoadingReport(
id="fq-lr-fast-ok",
recipe_id="fq-r1",
recipe_name="Тестовый рейс",
start_time=datetime.now(),
target_mixing_time=60,
actual_mixing_time=60,
total_weight=62.0,
created_by="system",
updated_by="system",
)
db.session.add(report)
db.session.flush()
db.session.add(
LoadingReportComponent(
report_id=report.id,
component_name="Сено",
target_weight=62.0,
actual_weight=62.0,
overload=0.0,
loading_order=1,
created_by="system",
updated_by="system",
)
)
now = datetime.now()
db.session.add(
ComponentLoadingTime(
report_id=report.id,
component_name="Сено",
start_time=now,
end_time=now,
loading_duration=0.9,
loading_order=1,
created_by="system",
updated_by="system",
)
)
db.session.commit()
rows = evaluate_loading_report(report.id, send_notifications=False)
fast = [r for r in rows if r.component_name == "Сено"]
self.assertEqual(len(fast), 1)
self.assertEqual(fast[0].event_type, EVENT_LOADING_FAST)
def test_slow_loading_separate_when_weight_ok(self) -> None:
save_feed_quality_settings({"loading": {"warning_max_sec": 30, "critical_max_sec": 120}})
report = LoadingReport(
id="fq-lr-slow",
recipe_id="fq-r1",
recipe_name="Тестовый рейс",
start_time=datetime.now(),
target_mixing_time=60,
actual_mixing_time=60,
total_weight=100.0,
created_by="system",
updated_by="system",
)
db.session.add(report)
db.session.flush()
db.session.add(
LoadingReportComponent(
report_id=report.id,
component_name="Силос",
target_weight=100.0,
actual_weight=100.0,
overload=0.0,
loading_order=1,
created_by="system",
updated_by="system",
)
)
now = datetime.now()
db.session.add(
ComponentLoadingTime(
report_id=report.id,
component_name="Силос",
start_time=now,
end_time=now,
loading_duration=90.0,
loading_order=1,
created_by="system",
updated_by="system",
)
)
db.session.commit()
rows = evaluate_loading_report(report.id, send_notifications=False)
slow = [r for r in rows if r.component_name == "Силос"]
self.assertEqual(len(slow), 1)
self.assertEqual(slow[0].event_type, EVENT_LOADING_TIME)
def test_left_in_mixer_on_unloading(self) -> None:
report_id = self._loading_with_overload()
unloading = UnloadingReport(
id="fq-ur-1",
recipe_id="fq-r1",
recipe_name="Тестовый рейс",
loading_report_id=report_id,
start_time=datetime.now(),
total_weight=100.0,
total_unloaded_weight=90.0,
remaining_weight=10.0,
created_by="system",
updated_by="system",
)
db.session.add(unloading)
db.session.flush()
db.session.add(
UnloadingReportGroup(
report_id=unloading.id,
name="G1",
target_weight=100.0,
unloaded_weight=90.0,
remaining_weight=10.0,
distribution_type="percent",
distribution_value=100,
order=1,
created_by="system",
updated_by="system",
)
)
db.session.commit()
rows = evaluate_unloading_report(unloading.id, send_notifications=False)
types = {r.event_type for r in rows}
self.assertIn(EVENT_LEFT_IN_MIXER, types)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,27 @@
"""Миграция feed_quality_settings."""
from __future__ import annotations
import unittest
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
class FeedQualityMigrationTests(unittest.TestCase):
def test_migration_file_exists(self) -> None:
path = PROJECT_ROOT / "migrations/versions/0002_feed_quality_settings.py"
self.assertTrue(path.exists())
text = path.read_text(encoding="utf-8")
self.assertIn('revision = "feed_quality_settings"', text)
self.assertIn('down_revision = "wesp_2_0"', text)
self.assertIn("feed_quality_settings", text)
def test_model_registered(self) -> None:
from app.models import FeedQualitySettings
self.assertEqual(FeedQualitySettings.__tablename__, "feed_quality_settings")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,60 @@
"""Тесты feed_quality.notify (флаги notify_*)."""
from __future__ import annotations
import unittest
from datetime import datetime
from app import create_app, db
from app.models.feed_alert import FeedAlert
from app.services.feed_quality.notify import notify_feed_quality_alerts
from app.services.feed_quality.rules import EVENT_OVERLOAD, SEVERITY_ERROR, SEVERITY_WARNING
from app.services.feed_quality.settings_store import save_feed_quality_settings
from app.models import ZootechNotification
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class FeedQualityNotifyTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def _alert(self, *, severity: str) -> FeedAlert:
return FeedAlert(
id="fq-nt-1",
event_type=EVENT_OVERLOAD,
severity=severity,
loading_report_id="lr-1",
recipe_id="r-1",
recipe_name="Рейс",
component_name="C1",
detail="перегруз",
created_at=datetime.now(),
)
def test_notify_warning_and_critical_by_default(self) -> None:
n1 = notify_feed_quality_alerts([self._alert(severity=SEVERITY_WARNING)])
n2 = notify_feed_quality_alerts([self._alert(severity=SEVERITY_ERROR)])
self.assertEqual(n1, 1)
self.assertEqual(n2, 1)
self.assertEqual(db.session.query(ZootechNotification).count(), 2)
def test_notify_warning_disabled(self) -> None:
save_feed_quality_settings({"loading": {"notify_warning": False}})
count = notify_feed_quality_alerts([self._alert(severity=SEVERITY_WARNING)])
self.assertEqual(count, 0)
def test_notify_critical_disabled(self) -> None:
save_feed_quality_settings({"loading": {"notify_critical": False}})
count = notify_feed_quality_alerts([self._alert(severity=SEVERITY_ERROR)])
self.assertEqual(count, 0)
if __name__ == "__main__":
unittest.main()
+199
View File
@@ -0,0 +1,199 @@
"""Unit-тесты порогов feed_quality.rules."""
from __future__ import annotations
import unittest
from app import create_app, db
from app.services.feed_quality.rules import (
EVENT_LEFT_IN_MIXER,
EVENT_LOADING_FAST,
EVENT_LOADING_TIME,
EVENT_MIX_TIME,
EVENT_OVERLOAD,
EVENT_UNDERLOAD,
SEVERITY_ERROR,
SEVERITY_WARNING,
TIME_ISSUE_FAST,
classify_component_loading_time,
classify_left_in_mixer,
classify_loading_component,
classify_mix_time,
classify_unloading_group,
merge_component_loading_alerts,
)
from app.services.feed_quality.settings_store import save_feed_quality_settings
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class FeedQualityRulesTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_loading_overload_at_threshold(self) -> None:
alerts = classify_loading_component(
component_name="Силос",
target_kg=100.0,
actual_kg=111.0,
price_rub_per_kg=10.0,
)
self.assertEqual(len(alerts), 1)
self.assertEqual(alerts[0].event_type, EVENT_OVERLOAD)
self.assertAlmostEqual(alerts[0].deviation_pct or 0, 11.0, places=1)
self.assertEqual(alerts[0].cost_deviation_rub, 110.0)
def test_loading_under_threshold_no_alert(self) -> None:
alerts = classify_loading_component(
component_name="Силос",
target_kg=100.0,
actual_kg=105.0,
)
self.assertEqual(alerts, [])
def test_loading_underload(self) -> None:
alerts = classify_loading_component(
component_name="Силос",
target_kg=100.0,
actual_kg=85.0,
)
self.assertEqual(len(alerts), 1)
self.assertEqual(alerts[0].event_type, EVENT_UNDERLOAD)
def test_overload_error_severity(self) -> None:
alerts = classify_loading_component(
component_name="X",
target_kg=100.0,
actual_kg=120.0,
)
self.assertEqual(alerts[0].severity, SEVERITY_ERROR)
def test_loading_disabled(self) -> None:
save_feed_quality_settings({"loading": {"enabled": False}})
alerts = classify_loading_component(
component_name="X",
target_kg=100.0,
actual_kg=50.0,
)
self.assertEqual(alerts, [])
def test_custom_loading_thresholds(self) -> None:
save_feed_quality_settings({"loading": {"warning_pct": 20, "critical_pct": 25}})
alerts = classify_loading_component(
component_name="X",
target_kg=100.0,
actual_kg=115.0,
)
self.assertEqual(alerts, [])
alerts2 = classify_loading_component(
component_name="X",
target_kg=100.0,
actual_kg=122.0,
)
self.assertEqual(len(alerts2), 1)
self.assertEqual(alerts2[0].severity, SEVERITY_WARNING)
def test_mix_time_alert(self) -> None:
alerts = classify_mix_time(target_sec=300, actual_sec=340)
self.assertEqual(len(alerts), 1)
self.assertEqual(alerts[0].event_type, EVENT_MIX_TIME)
self.assertEqual(alerts[0].severity, SEVERITY_WARNING)
def test_mix_time_critical(self) -> None:
alerts = classify_mix_time(target_sec=300, actual_sec=400)
self.assertEqual(alerts[0].severity, SEVERITY_ERROR)
def test_mix_time_within_tolerance(self) -> None:
self.assertEqual(classify_mix_time(target_sec=300, actual_sec=320), [])
def test_mix_time_disabled(self) -> None:
save_feed_quality_settings({"mix_time": {"enabled": False}})
self.assertEqual(classify_mix_time(target_sec=300, actual_sec=400), [])
def test_unloading_group_overload(self) -> None:
alerts = classify_unloading_group(
group_name="Группа 1",
target_kg=100.0,
unloaded_kg=108.0,
)
self.assertEqual(len(alerts), 1)
self.assertEqual(alerts[0].event_type, EVENT_OVERLOAD)
def test_left_in_mixer_warning(self) -> None:
alerts = classify_left_in_mixer(remaining_kg=8.0, total_kg=200.0)
self.assertEqual(len(alerts), 1)
self.assertEqual(alerts[0].event_type, EVENT_LEFT_IN_MIXER)
self.assertEqual(alerts[0].severity, SEVERITY_WARNING)
def test_left_in_mixer_critical(self) -> None:
alerts = classify_left_in_mixer(remaining_kg=16.0, total_kg=200.0)
self.assertEqual(alerts[0].severity, SEVERITY_ERROR)
def test_left_in_mixer_below_threshold(self) -> None:
self.assertEqual(classify_left_in_mixer(remaining_kg=2.0, total_kg=500.0), [])
def test_loading_time_too_fast_merged_not_standalone(self) -> None:
time_issue = classify_component_loading_time(component_name="Сено", duration_sec=0.9)
self.assertIsNotNone(time_issue)
self.assertEqual(time_issue.kind, TIME_ISSUE_FAST)
weight = classify_loading_component(
component_name="Сено", target_kg=62.0, actual_kg=0.0
)[0]
merged = merge_component_loading_alerts(
component_name="Сено",
weight_alert=weight,
time_issue=time_issue,
)
self.assertEqual(len(merged), 1)
self.assertEqual(merged[0].event_type, EVENT_UNDERLOAD)
self.assertIn("загрузка 0.9 с", merged[0].detail)
def test_loading_time_fast_standalone_when_weight_ok(self) -> None:
time_issue = classify_component_loading_time(component_name="Сено", duration_sec=0.9)
alerts = merge_component_loading_alerts(
component_name="Сено",
weight_alert=None,
time_issue=time_issue,
)
self.assertEqual(len(alerts), 1)
self.assertEqual(alerts[0].event_type, EVENT_LOADING_FAST)
self.assertIn("0.9 с", alerts[0].detail)
def test_loading_time_slow_standalone_when_weight_ok(self) -> None:
save_feed_quality_settings({"loading": {"warning_max_sec": 60, "critical_max_sec": 120}})
time_issue = classify_component_loading_time(component_name="Силос", duration_sec=90.0)
self.assertIsNotNone(time_issue)
alerts = merge_component_loading_alerts(
component_name="Силос",
weight_alert=None,
time_issue=time_issue,
)
self.assertEqual(len(alerts), 1)
self.assertEqual(alerts[0].event_type, EVENT_LOADING_TIME)
self.assertEqual(alerts[0].severity, SEVERITY_WARNING)
def test_loading_time_slow_merged_when_weight_bad(self) -> None:
save_feed_quality_settings({"loading": {"warning_max_sec": 60, "critical_max_sec": 120}})
time_issue = classify_component_loading_time(component_name="X", duration_sec=200.0)
weight = classify_loading_component(
component_name="X", target_kg=100.0, actual_kg=111.0
)[0]
merged = merge_component_loading_alerts(
component_name="X",
weight_alert=weight,
time_issue=time_issue,
)
self.assertEqual(len(merged), 1)
self.assertEqual(merged[0].event_type, EVENT_OVERLOAD)
self.assertEqual(merged[0].severity, SEVERITY_ERROR)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,108 @@
"""API /api/feed-quality/settings."""
from __future__ import annotations
import unittest
from datetime import datetime
from app import create_app, db
from app.models import LoadingReport, LoadingReportComponent, Recipe
from app.models.feed_quality_settings import FeedQualitySettings
from app.services.feed_quality.evaluator import evaluate_loading_report
from app.services.setup_state import mark_setup_complete
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class FeedQualitySettingsApiTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
self.client.post(
"/api/auth/login",
json={"login": "zootech-test-admin", "password": "zootech-test-secret"},
)
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_get_settings(self) -> None:
resp = self.client.get("/api/feed-quality/settings")
self.assertEqual(resp.status_code, 200)
body = resp.get_json()
self.assertIn("settings", body)
self.assertIn("defaults", body)
self.assertTrue(body["settings"]["loading"]["enabled"])
def test_put_disables_loading_and_reevaluates(self) -> None:
db.session.add(Recipe(id="set-r1", name="Set рейс", heads_per_trip=1, content_hash=""))
report = LoadingReport(
id="set-lr-1",
recipe_id="set-r1",
recipe_name="Set рейс",
start_time=datetime.now(),
target_mixing_time=60,
actual_mixing_time=60,
total_weight=50.0,
created_by="system",
updated_by="system",
)
db.session.add(report)
db.session.flush()
db.session.add(
LoadingReportComponent(
report_id=report.id,
component_name="C1",
target_weight=50.0,
actual_weight=60.0,
overload=10.0,
loading_order=1,
created_by="system",
updated_by="system",
)
)
db.session.commit()
evaluate_loading_report(report.id, send_notifications=False)
resp = self.client.put(
"/api/feed-quality/settings",
json={"settings": {"loading": {"enabled": False}}},
)
self.assertEqual(resp.status_code, 200)
body = resp.get_json()
self.assertTrue(body.get("saved"))
self.assertFalse(body["settings"]["loading"]["enabled"])
self.assertIn("reevaluatedReports", body)
row = db.session.get(FeedQualitySettings, 1)
self.assertIsNotNone(row)
alerts = self.client.get("/api/feed-quality/alerts")
self.assertEqual(alerts.status_code, 200)
items = alerts.get_json().get("items") or []
self.assertEqual(len(items), 0)
def test_put_persists_to_db(self) -> None:
resp = self.client.put(
"/api/feed-quality/settings",
json={"settings": {"loading": {"warning_pct": 13}}},
)
self.assertEqual(resp.status_code, 200)
row = db.session.get(FeedQualitySettings, 1)
self.assertIsNotNone(row)
get_resp = self.client.get("/api/feed-quality/settings")
self.assertEqual(get_resp.get_json()["settings"]["loading"]["warning_pct"], 13.0)
def test_requires_auth(self) -> None:
self.client.post("/api/auth/logout")
resp = self.client.get("/api/feed-quality/settings")
self.assertEqual(resp.status_code, 401)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,107 @@
"""Тесты feed_quality.settings_store (recipes.db)."""
from __future__ import annotations
import json
import os
import tempfile
import unittest
from sqlalchemy import create_engine, text
from app import create_app, db
from app.models.feed_quality_settings import FeedQualitySettings
from app.services.feed_quality.settings_store import (
create_feed_quality_settings_table,
default_settings,
get_feed_quality_settings,
get_legacy_json_path,
import_feed_quality_settings_json,
import_legacy_json_to_db,
normalize_settings,
save_feed_quality_settings,
)
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class FeedQualitySettingsStoreTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
os.environ.pop("WESP_FEED_QUALITY_SETTINGS_PATH", None)
def test_defaults(self) -> None:
d = default_settings()
self.assertTrue(d["loading"]["enabled"])
self.assertEqual(d["loading"]["warning_pct"], 10.0)
def test_normalize_critical_not_below_warning(self) -> None:
out = normalize_settings({"loading": {"warning_pct": 12, "critical_pct": 8}})
self.assertEqual(out["loading"]["critical_pct"], 12.0)
def test_save_and_load_from_db(self) -> None:
save_feed_quality_settings({"loading": {"enabled": False, "warning_pct": 11}})
loaded = get_feed_quality_settings()
self.assertFalse(loaded["loading"]["enabled"])
self.assertEqual(loaded["loading"]["warning_pct"], 11.0)
row = db.session.get(FeedQualitySettings, 1)
self.assertIsNotNone(row)
payload = json.loads(row.payload)
self.assertFalse(payload["loading"]["enabled"])
def test_partial_save_preserves_other_loading_fields(self) -> None:
save_feed_quality_settings({"loading": {"warning_pct": 12.5}})
save_feed_quality_settings({"loading": {"enabled": False}})
loaded = get_feed_quality_settings()
self.assertFalse(loaded["loading"]["enabled"])
self.assertEqual(loaded["loading"]["warning_pct"], 12.5)
def test_import_legacy_json_to_db(self) -> None:
tmp = tempfile.mkdtemp()
path = os.path.join(tmp, "legacy.json")
os.environ["WESP_FEED_QUALITY_SETTINGS_PATH"] = path
with open(path, "w", encoding="utf-8") as fh:
json.dump({"loading": {"enabled": False, "warning_pct": 9}}, fh)
self.assertTrue(import_legacy_json_to_db())
loaded = get_feed_quality_settings()
self.assertFalse(loaded["loading"]["enabled"])
self.assertEqual(loaded["loading"]["warning_pct"], 9.0)
def test_import_json_via_bind(self) -> None:
tmp = tempfile.mkdtemp()
path = os.path.join(tmp, "legacy_bind.json")
os.environ["WESP_FEED_QUALITY_SETTINGS_PATH"] = path
with open(path, "w", encoding="utf-8") as fh:
json.dump({"unloading": {"warning_pct": 7}}, fh)
db.session.remove()
db.drop_all()
engine = create_engine(self.app.config["SQLALCHEMY_DATABASE_URI"])
create_feed_quality_settings_table(engine)
self.assertTrue(import_feed_quality_settings_json(engine))
with engine.connect() as conn:
row = conn.execute(
text("SELECT payload FROM feed_quality_settings WHERE id = 1")
).fetchone()
payload = json.loads(row[0])
self.assertEqual(payload["unloading"]["warning_pct"], 7.0)
def test_get_creates_defaults_when_empty(self) -> None:
loaded = get_feed_quality_settings()
self.assertTrue(loaded["loading"]["enabled"])
row = db.session.get(FeedQualitySettings, 1)
self.assertIsNotNone(row)
def test_legacy_path_env(self) -> None:
os.environ["WESP_FEED_QUALITY_SETTINGS_PATH"] = "/tmp/custom_fq.json"
self.assertTrue(str(get_legacy_json_path()).endswith("custom_fq.json"))
if __name__ == "__main__":
unittest.main()
+95
View File
@@ -0,0 +1,95 @@
"""feed_quality после sync push."""
from __future__ import annotations
import unittest
import uuid
from datetime import datetime
from sqlalchemy import select
from app import create_app, db
from app.models import Recipe
from app.models.feed_alert import FeedAlert
from app.services.setup_state import mark_setup_complete
from app.services.sync_manager import SyncManager
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class FeedQualitySyncTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
recipe = Recipe(
name="Sync FQ",
created_by="system",
updated_by="system",
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
db.session.add(recipe)
db.session.commit()
self.recipe_id = recipe.id
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_push_overload_report_creates_alert(self) -> None:
report_id = str(uuid.uuid4())
changes = [
{
"table_name": "loading_report",
"record_id": report_id,
"action": "create",
"data": {
"id": report_id,
"recipe_id": self.recipe_id,
"recipe_name": "Sync FQ",
"start_time": "2026-06-07T10:00:00",
"target_mixing_time": 300,
"actual_mixing_time": 300,
"dispenser_type": "dispenser",
"version": 1,
"created_by": "terminal",
"updated_by": "terminal",
"content_hash": "abc",
},
},
{
"table_name": "loading_report_component",
"record_id": str(uuid.uuid4()),
"action": "create",
"data": {
"report_id": report_id,
"component_name": "C1",
"target_weight": 100.0,
"actual_weight": 112.0,
"overload": 12.0,
"loading_order": 1,
"version": 1,
"created_by": "terminal",
"updated_by": "terminal",
"content_hash": "def",
},
},
]
result = SyncManager.process_push(client_id="node-fq-1", changes=changes)
self.assertEqual(result["status_code"], 200, result)
alerts = (
db.session.execute(
select(FeedAlert).where(FeedAlert.loading_report_id == report_id)
)
.scalars()
.all()
)
self.assertGreaterEqual(len(alerts), 1)
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
"""Чистая установка: пустой data/, Alembic, bootstrap, health."""
from __future__ import annotations
import os
import shutil
import tempfile
import unittest
from pathlib import Path
from app import create_app, db
from app.models.auto_update_settings import AutoUpdateSettings
from app.services.update_health import migrations_ok_for_app
from config import Config, ProductionConfig
class FreshInstallTest(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.mkdtemp(prefix="wesp-fresh-install-")
self._base = Path(self._tmp) / "wesp_home"
self._base.mkdir()
data = self._base / "data"
data.mkdir()
recipes = data / "recipes.db"
reports = data / "reports.db"
class FreshConfig(ProductionConfig):
BASE_DIR = str(self._base)
DATA_DIR = str(data)
SQLALCHEMY_DATABASE_URI = f"sqlite:///{recipes}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{reports}"}
SECRET_KEY = "fresh-install-test-secret"
AUTH_LOGIN = "install-admin"
AUTH_PASSWORD = "install-secret"
SYNC_BACKGROUND_REQUEUE = False
SYNC_CLIENT_AUTOSTART = False
WESP_ADMIN_LOG_PATH = ""
WESP_ADMIN_HARDWARE_LOG_PATH = ""
WESP_ADMIN_UI_ACTIVITY_LOG_PATH = ""
WESP_LLM_AUTOSTART = False
WESP_BACKGROUND_STARTUP = False
self.app = create_app(FreshConfig, run_migrations=True)
self.client = self.app.test_client()
self._ctx = self.app.app_context()
self._ctx.push()
def tearDown(self) -> None:
self._ctx.pop()
shutil.rmtree(self._tmp, ignore_errors=True)
def test_ensure_auto_update_row_idempotent(self) -> None:
from sqlalchemy import func, select
from app.services.auto_update_settings_service import ensure_auto_update_row
ensure_auto_update_row()
ensure_auto_update_row()
n = db.session.scalar(select(func.count()).select_from(AutoUpdateSettings))
self.assertEqual(int(n or 0), 1)
def test_databases_and_auto_update_bootstrap(self) -> None:
from app.services.auto_update_settings_service import bootstrap_update_environment
recipes = Path(self.app.config["DATA_DIR"]) / "recipes.db"
reports = Path(self.app.config["DATA_DIR"]) / "reports.db"
self.assertTrue(recipes.is_file())
self.assertTrue(reports.is_file())
# Как при prod-старте, но синхронно: отложенный поток под нагрузкой pytest может не успеть за 5 с.
bootstrap_update_environment(self.app)
row = db.session.get(AutoUpdateSettings, 1)
self.assertIsNotNone(row)
config_json = Path(self.app.config["DATA_DIR"]) / "config.json"
self.assertTrue(config_json.is_file())
def test_migrations_at_head(self) -> None:
self.assertTrue(migrations_ok_for_app(self.app))
def test_health_after_fresh_install(self) -> None:
r = self.client.get("/api/health")
self.assertEqual(r.status_code, 200)
body = r.get_json()
self.assertTrue(body.get("ok"))
self.assertTrue(body.get("db_ok"))
self.assertTrue(body.get("migrations_ok"))
self.assertEqual(body.get("migration_head"), body.get("migration_revision"))
def test_setup_not_completed_by_default(self) -> None:
from app.services.setup_state import is_setup_completed
self.assertFalse(is_setup_completed(self.app))
def test_alembic_env_does_not_bootstrap_before_upgrade(self) -> None:
"""create_app(run_migrations=False) не обращается к таблицам до upgrade (как migrations/env.py)."""
empty = Path(self._tmp) / "empty"
empty.mkdir()
data = empty / "data"
data.mkdir()
recipes = data / "recipes.db"
reports = data / "reports.db"
class EmptyConfig(Config):
BASE_DIR = str(empty)
DATA_DIR = str(data)
SQLALCHEMY_DATABASE_URI = f"sqlite:///{recipes}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{reports}"}
SECRET_KEY = "empty"
SYNC_BACKGROUND_REQUEUE = False
WESP_ADMIN_LOG_PATH = ""
os.environ["WESP_ALEMBIC"] = "1"
try:
app = create_app(EmptyConfig, run_migrations=False)
self.assertIsNotNone(app)
finally:
os.environ.pop("WESP_ALEMBIC", None)
if __name__ == "__main__":
unittest.main()
+48
View File
@@ -0,0 +1,48 @@
import time
import unittest
from app.services.hardware import GPIOController
class GPIOControllerTests(unittest.TestCase):
def setUp(self) -> None:
GPIOController._reset_for_tests()
def tearDown(self) -> None:
GPIOController._reset_for_tests()
def test_singleton_instance(self) -> None:
c1 = GPIOController.get_instance(pin=18, simulation_mode=True)
c2 = GPIOController.get_instance(pin=99, simulation_mode=False)
self.assertIs(c1, c2)
def test_on_off_state_transitions(self) -> None:
ctrl = GPIOController.get_instance(pin=18, simulation_mode=True)
self.assertFalse(ctrl.rpi_gpio_available)
self.assertFalse(ctrl.is_on())
ctrl.on()
self.assertTrue(ctrl.is_on())
ctrl.off()
self.assertFalse(ctrl.is_on())
def test_blink_runs_in_background_and_finishes_off(self) -> None:
ctrl = GPIOController.get_instance(pin=18, simulation_mode=True)
start = time.time()
ctrl.blink(times=2, delay=0.02)
# non-blocking start
self.assertLess(time.time() - start, 0.05)
time.sleep(0.15)
self.assertFalse(ctrl.is_on())
def test_stop_blink(self) -> None:
ctrl = GPIOController.get_instance(pin=18, simulation_mode=True)
ctrl.blink(times=10, delay=0.05)
time.sleep(0.06)
ctrl.stop_blink()
self.assertFalse(ctrl.is_on())
if __name__ == "__main__":
unittest.main()
+78
View File
@@ -0,0 +1,78 @@
"""Тесты favicon и прочих вставок в <head>."""
from __future__ import annotations
import unittest
from flask import Response, make_response
from app.html_head_injects import apply_html_head_injects, inject_favicon_html
class HtmlHeadInjectsTests(unittest.TestCase):
def test_inject_favicon_into_head(self) -> None:
html = "<!DOCTYPE html><html><head><title>x</title></head><body></body></html>"
out = inject_favicon_html(html)
self.assertIn('rel="icon"', out)
self.assertIn('href="/static/favicon.svg"', out)
self.assertIn("apple-touch-icon", out)
def test_inject_favicon_idempotent(self) -> None:
html = (
'<!DOCTYPE html><html><head>'
'<link rel="icon" href="/custom.ico">'
"<title>x</title></head><body></body></html>"
)
out = inject_favicon_html(html)
self.assertEqual(out.count('rel="icon"'), 1)
self.assertIn("/custom.ico", out)
def test_apply_on_make_response(self) -> None:
from flask import Flask
app = Flask(__name__)
html = "<!DOCTYPE html><html><head><title>x</title></head><body></body></html>"
with app.app_context():
resp = make_response(html)
out = apply_html_head_injects(resp, "/login")
body = out.get_data(as_text=True)
self.assertIn('rel="icon"', body)
def test_kiosk_cursor_hidden_on_calibration_path(self) -> None:
from flask import Flask
app = Flask(__name__)
app.config["WESP_KIOSK_HIDE_CURSOR"] = True
html = "<!DOCTYPE html><html><head><title>x</title></head><body></body></html>"
with app.app_context():
resp = make_response(html)
out = apply_html_head_injects(resp, "/calibration")
body = out.get_data(as_text=True)
self.assertIn("kiosk-cursor.css", body)
self.assertIn("wesp-kiosk-hide-cursor", body)
def test_kiosk_cursor_visible_on_scales_path(self) -> None:
from flask import Flask
app = Flask(__name__)
app.config["WESP_KIOSK_HIDE_CURSOR"] = True
html = "<!DOCTYPE html><html><head><title>x</title></head><body></body></html>"
with app.app_context():
resp = make_response(html)
out = apply_html_head_injects(resp, "/scales")
body = out.get_data(as_text=True)
self.assertNotIn("kiosk-cursor.css", body)
self.assertNotIn("wesp-kiosk-hide-cursor", body)
def test_apply_skips_direct_passthrough(self) -> None:
resp = Response(
"<!DOCTYPE html><html><head><title>x</title></head><body></body></html>",
mimetype="text/html",
direct_passthrough=True,
)
out = apply_html_head_injects(resp, "/login")
self.assertIs(out, resp)
if __name__ == "__main__":
unittest.main()
+80
View File
@@ -0,0 +1,80 @@
import unittest
from unittest.mock import MagicMock, patch
from app.services.hardware import HX711UnavailableError, HX711Wrapper
class HX711WrapperTests(unittest.TestCase):
def test_simulation_mode_returns_non_negative_samples(self) -> None:
wrapper = HX711Wrapper(simulation_mode=True)
values = [wrapper.read_weight_sample() for _ in range(20)]
self.assertEqual(len(values), 20)
for value in values:
self.assertIsInstance(value, float)
self.assertGreaterEqual(value, 0.0)
def test_apply_simulation_mode_initializes_device_when_disabled(self) -> None:
wrapper = HX711Wrapper(simulation_mode=True)
self.assertIsNone(wrapper._device)
device = MagicMock()
with unittest.mock.patch.dict(
"sys.modules",
{"hx711": MagicMock(HX711=MagicMock(return_value=device))},
):
wrapper.apply_simulation_mode(False)
self.assertFalse(wrapper.simulation_mode)
self.assertIs(wrapper._device, device)
def test_apply_simulation_mode_clears_device_when_enabled(self) -> None:
wrapper = HX711Wrapper.__new__(HX711Wrapper)
wrapper._sim_weight = 0.0
wrapper._dout_pin = 2
wrapper._pd_sck_pin = 3
wrapper._device = MagicMock()
wrapper.simulation_mode = False
wrapper.apply_simulation_mode(True)
self.assertTrue(wrapper.simulation_mode)
self.assertIsNone(wrapper._device)
def test_non_simulation_raises_when_no_device(self) -> None:
wrapper = HX711Wrapper.__new__(HX711Wrapper)
wrapper.simulation_mode = False
wrapper._device = None
wrapper._dout_pin = 2
wrapper._pd_sck_pin = 3
wrapper._sim_weight = 0.0
with self.assertRaises(HX711UnavailableError):
wrapper.read_weight_sample()
def test_read_raw_mean_via_get_raw_data_list(self) -> None:
wrapper = HX711Wrapper.__new__(HX711Wrapper)
wrapper.simulation_mode = False
wrapper._sim_weight = 0.0
wrapper._dout_pin = 2
wrapper._pd_sck_pin = 3
device = MagicMock(spec=["get_raw_data"])
device.get_raw_data.return_value = [-28000, -28020, -28010]
wrapper._device = device
value = wrapper.read_weight_sample()
self.assertAlmostEqual(value, -28010.0, places=1)
device.get_raw_data.assert_called_once_with(times=3)
def test_read_raw_mean_via_get_raw_data_mean(self) -> None:
wrapper = HX711Wrapper.__new__(HX711Wrapper)
wrapper.simulation_mode = False
wrapper._sim_weight = 0.0
wrapper._dout_pin = 2
wrapper._pd_sck_pin = 3
device = MagicMock(spec=["get_raw_data_mean"])
device.get_raw_data_mean.return_value = 12345.0
wrapper._device = device
self.assertEqual(wrapper.read_weight_sample(), 12345.0)
device.get_raw_data_mean.assert_called_once_with(3)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,94 @@
"""Тесты offline install_requirements (OTA)."""
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from install_deps import UpdateDepsError, install_requirements, wheelhouse_has_packages
class InstallDepsOfflineTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.mkdtemp(prefix="wesp-deps-")
self.root = Path(self._tmp)
(self.root / "requirements-prod.txt").write_text("Flask==2.3.3\n", encoding="utf-8")
self.wheels = self.root / "vendor" / "wheels"
self.wheels.mkdir(parents=True)
(self.wheels / "flask-2.3.3-py3-none-any.whl").write_bytes(b"x")
def tearDown(self) -> None:
import shutil
shutil.rmtree(self._tmp, ignore_errors=True)
@patch("install_deps.subprocess.check_call")
def test_offline_only_ignores_pypi_allow(self, mock_call) -> None:
install_requirements(self.root, offline_only=True)
cmd = mock_call.call_args[0][0]
self.assertIn("--no-index", cmd)
self.assertIn("--ignore-installed", cmd)
self.assertTrue(any("--find-links=" in str(a) for a in cmd))
@patch("install_deps.subprocess.check_call")
def test_offline_uses_wesp_venv(self, mock_call) -> None:
venv = self.root / "prod_venv"
bindir = venv / "bin"
bindir.mkdir(parents=True)
py = bindir / "python3"
py.write_text("#!/bin/sh\necho venv\n", encoding="utf-8")
py.chmod(0o755)
with patch.dict(os.environ, {"WESP_VENV": str(venv)}, clear=False):
install_requirements(self.root, offline_only=True)
cmd = mock_call.call_args[0][0]
self.assertEqual(cmd[0], str(py.resolve()))
@patch("install_deps.subprocess.check_call")
def test_offline_uses_wes_mac_python_when_present(self, mock_call) -> None:
layout = Path(self._tmp) / "layout"
app = layout / "wesp"
app.mkdir(parents=True)
(app / "requirements-prod.txt").write_text("Flask==2.3.3\n", encoding="utf-8")
wheels = app / "vendor" / "wheels"
wheels.mkdir(parents=True)
(wheels / "flask-2.3.3-py3-none-any.whl").write_bytes(b"x")
wes_mac = layout / "wes_mac" / "bin"
wes_mac.mkdir(parents=True)
py = wes_mac / "python3"
py.write_text("#!/bin/sh\necho venv\n", encoding="utf-8")
py.chmod(0o755)
with patch.dict(os.environ, {}, clear=True):
install_requirements(app, offline_only=True)
cmd = mock_call.call_args[0][0]
self.assertEqual(cmd[0], str(py.resolve()))
@patch("install_deps.subprocess.check_call")
def test_offline_only_with_wheels(self, mock_call) -> None:
install_requirements(self.root, offline_only=True)
mock_call.assert_called_once()
def test_offline_only_empty_wheelhouse_raises(self) -> None:
for f in self.wheels.glob("*"):
f.unlink()
self.assertFalse(wheelhouse_has_packages(self.wheels))
with self.assertRaises(UpdateDepsError):
install_requirements(self.root, offline_only=True)
@patch.dict(os.environ, {"WESP_OTA_UPDATE": "1"}, clear=False)
@patch("install_deps.subprocess.check_call")
def test_ota_env_forces_offline(self, mock_call) -> None:
install_requirements(self.root, offline_only=False)
cmd = mock_call.call_args[0][0]
self.assertIn("--no-index", cmd)
@patch("install_deps.subprocess.check_call", side_effect=__import__("subprocess").CalledProcessError(1, "pip"))
def test_pip_failure_raises_update_deps_error(self, _mock) -> None:
with self.assertRaises(UpdateDepsError):
install_requirements(self.root, offline_only=True)
if __name__ == "__main__":
unittest.main()
+123
View File
@@ -0,0 +1,123 @@
"""Тесты автозапуска Chromium (kiosk boot)."""
from __future__ import annotations
import json
import os
import tempfile
import unittest
from pathlib import Path
from app.services.kiosk_boot_service import (
_DESKTOP_ENTRY_NAME,
_LABWC_MARKER,
_chromium_flag_list,
_kiosk_launch_url,
_kiosk_target_url,
build_chromium_desktop_content,
build_labwc_autostart_content,
get_kiosk_boot_status,
set_kiosk_boot,
)
class KioskBootServiceTests(unittest.TestCase):
def test_chromium_flags_disable_translate(self) -> None:
home = Path("/home/komton")
flags = " ".join(_chromium_flag_list({}, home=home))
self.assertIn("Translate,", flags)
self.assertIn("--host-rules=", flags)
self.assertIn("translate.googleapis.com", flags)
self.assertIn("--user-data-dir=", flags)
self.assertIn("wesp-kiosk-chromium", flags)
self.assertNotIn("--incognito", flags)
def test_launch_url_startup_dark(self) -> None:
cfg = {"WESP_KIOSK_TARGET_URL": "http://localhost/scales"}
url = _kiosk_launch_url(cfg)
self.assertIn("/starting?", url)
self.assertIn("next=/scales", url)
self.assertIn("theme=dark", url)
self.assertEqual(_kiosk_target_url(cfg), "http://localhost/scales")
def test_build_desktop_contains_url_and_kiosk(self) -> None:
home = Path("/home/komton")
cfg = {}
text = build_chromium_desktop_content(
chromium="/usr/bin/chromium",
url="http://localhost/starting?next=/scales",
app_config=cfg,
home=home,
)
self.assertIn("--kiosk", text)
self.assertIn("/starting", text)
def test_build_labwc_minimal(self) -> None:
home = Path("/home/komton")
text = build_labwc_autostart_content(
chromium="/usr/bin/chromium",
url="http://localhost/starting?next=/scales&theme=dark",
app_config={},
home=home,
)
self.assertIn(_LABWC_MARKER, text)
self.assertIn("/usr/bin/kanshi", text)
self.assertNotIn("pcmanfm", text)
self.assertIn("curl", text)
self.assertIn("127.0.0.1/starting", text)
self.assertIn("--app=", text)
def test_system_labwc_patch_roundtrip(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
home = Path(tmp) / "komton"
home.mkdir()
system = Path(tmp) / "labwc-autostart"
system.write_text(
"/usr/bin/lwrespawn /usr/bin/pcmanfm-pi &\n",
encoding="utf-8",
)
system.chmod(0o755)
policy = Path(tmp) / "policy.json"
fake_chromium = Path(tmp) / "chromium"
fake_chromium.write_text("#!/bin/sh\n", encoding="utf-8")
fake_chromium.chmod(0o755)
cfg = {
"WESP_KIOSK_GUI_USER": "komton",
"WESP_KIOSK_TARGET_URL": "http://localhost/scales",
"WESP_KIOSK_CHROMIUM": str(fake_chromium),
"WESP_KIOSK_SYSTEM_LABWC_AUTOSTART": str(system),
"WESP_KIOSK_CHROMIUM_POLICY_PATH": str(policy),
}
import app.services.kiosk_boot_service as mod
orig_home = mod._home_dir
mod._home_dir = lambda _user: home # type: ignore[assignment]
try:
res = set_kiosk_boot(True, cfg, hide_desktop=True)
self.assertTrue(res.get("ok"), res)
body = system.read_text(encoding="utf-8")
self.assertIn(_LABWC_MARKER, body)
self.assertIn("/starting", body)
self.assertIn("user-data-dir", body)
prefs = home / ".config" / "wesp-kiosk-chromium" / "Default" / "Preferences"
self.assertTrue(prefs.is_file())
prefs_data = json.loads(prefs.read_text(encoding="utf-8"))
self.assertFalse(prefs_data["translate"]["enabled"])
self.assertTrue(policy.is_file())
st = get_kiosk_boot_status(cfg)
self.assertEqual(st["kiosk_mode"], "system_labwc")
res2 = set_kiosk_boot(False, cfg)
self.assertTrue(res2.get("ok"))
self.assertIn("pcmanfm", system.read_text(encoding="utf-8"))
finally:
mod._home_dir = orig_home
if __name__ == "__main__":
unittest.main()
+70
View File
@@ -0,0 +1,70 @@
"""Тесты полной настройки киоска Pi."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from app.services.kiosk_full_setup_service import (
apply_full_kiosk_setup,
get_full_kiosk_setup_status,
)
class KioskFullSetupTests(unittest.TestCase):
@patch("app.services.kiosk_full_setup_service.get_kiosk_boot_status")
@patch("app.services.kiosk_full_setup_service.get_pi_platform_setup_status")
def test_status_ready(self, mock_pi: object, mock_kiosk: object) -> None:
mock_pi.return_value = {
"running_as_root": True,
"platform_ready": True,
}
mock_kiosk.return_value = {
"kiosk_enabled": True,
"kiosk_mode": "system_labwc",
"chromium_found": True,
"gui_user": "komton",
}
st = get_full_kiosk_setup_status({})
self.assertTrue(st["full_setup_ready"])
@patch(
"app.services.kiosk_full_setup_service.get_full_kiosk_setup_status",
return_value={"full_setup_ready": False},
)
@patch("app.services.kiosk_full_setup_service.schedule_system_reboot")
@patch("app.services.kiosk_full_setup_service.set_kiosk_boot")
@patch("app.services.kiosk_full_setup_service.apply_pi_platform_setup")
@patch("app.services.kiosk_full_setup_service._is_root", return_value=True)
def test_apply_ok(
self,
_root: object,
mock_pi: object,
mock_kiosk: object,
mock_reboot: object,
_status: object,
) -> None:
mock_pi.return_value = {"ok": True, "message": "pi ok"}
mock_kiosk.return_value = {
"ok": True,
"message": "kiosk ok",
"target_url": "http://localhost/scales",
}
mock_reboot.return_value = {"scheduled": True, "message": "reboot"}
cfg = {"WESP_FULL_SETUP_REBOOT_DELAY_SEC": 10}
r = apply_full_kiosk_setup(cfg, hide_desktop=True)
self.assertTrue(r.get("ok"))
self.assertEqual(r.get("steps_completed"), ["pi_platform", "kiosk", "reboot"])
mock_pi.assert_called_once()
mock_kiosk.assert_called_once()
mock_reboot.assert_called_once()
@patch("app.services.kiosk_full_setup_service._is_root", return_value=False)
def test_apply_requires_root(self, _root: object) -> None:
r = apply_full_kiosk_setup({})
self.assertFalse(r.get("ok"))
if __name__ == "__main__":
unittest.main()
+50
View File
@@ -0,0 +1,50 @@
"""Тесты подсказок «не переводить» для страниц киоска."""
from __future__ import annotations
import unittest
from flask import Response
from app.kiosk_page_hints import (
apply_kiosk_translate_hints,
inject_no_translate_html,
is_kiosk_cursor_hidden_path,
is_kiosk_html_path,
)
class KioskPageHintsTests(unittest.TestCase):
def test_paths(self) -> None:
self.assertTrue(is_kiosk_html_path("/scales"))
self.assertTrue(is_kiosk_html_path("/starting"))
self.assertFalse(is_kiosk_html_path("/login"))
def test_cursor_hidden_except_scales(self) -> None:
self.assertFalse(is_kiosk_cursor_hidden_path("/scales"))
self.assertTrue(is_kiosk_cursor_hidden_path("/starting"))
self.assertTrue(is_kiosk_cursor_hidden_path("/calibration"))
self.assertFalse(is_kiosk_cursor_hidden_path("/login"))
def test_apply_hints_passthrough_response(self) -> None:
resp = Response(
"<html><body></body></html>",
mimetype="text/html",
direct_passthrough=True,
)
out = apply_kiosk_translate_hints(resp, "/starting")
self.assertIs(out, resp)
self.assertEqual(out.headers.get("Content-Language"), "ru")
self.assertEqual(out.headers.get("Google-Translate"), "no")
self.assertTrue(getattr(out, "direct_passthrough", False))
def test_inject_meta_and_html_attrs(self) -> None:
html = "<!DOCTYPE html><html lang='ru'><head><title>x</title></head><body></body></html>"
out = inject_no_translate_html(html)
self.assertIn('content="notranslate"', out)
self.assertIn('translate="no"', out)
self.assertIn("notranslate", out)
if __name__ == "__main__":
unittest.main()
+466
View File
@@ -0,0 +1,466 @@
import base64
import os
import tempfile
import unittest
from unittest.mock import patch
from app import create_app, db
from config import TestingConfig
class KioskPairConfig(TestingConfig):
_TMP_DIR = tempfile.mkdtemp(prefix="wesp-kiosk-pair-tests-")
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(_TMP_DIR, 'recipes_test.db')}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(_TMP_DIR, 'reports_test.db')}"}
SIMULATION_MODE = True
KIOSK_ENFORCE_PAIRED_ONLY = True
KIOSK_PAIR_TOKEN_TTL_SECONDS = 120
KIOSK_PUBLIC_BASE_URL = ""
class KioskPairingTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(KioskPairConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
self.client.get("/scales")
def tearDown(self) -> None:
from app.routes import scales as scales_module
reader = getattr(scales_module, "_scales_reader", None)
if reader is not None:
reader.stop()
scales_module._scales_reader = None
db.session.remove()
db.drop_all()
self.ctx.pop()
def _confirm_post(self, token: str):
return self.client.post(
"/api/kiosk/pair/confirm",
data={"token": token},
content_type="application/x-www-form-urlencoded",
)
def _pair(self) -> str:
pair = self.client.post("/api/kiosk/pair-token")
self.assertEqual(pair.status_code, 200)
token = pair.get_json().get("token")
self.assertTrue(token)
page = self.client.get(f"/api/kiosk/pair/confirm?token={token}")
self.assertEqual(page.status_code, 200)
self.assertIn("Подтвердить привязку", page.get_data(as_text=True))
ok = self._confirm_post(token)
self.assertEqual(ok.status_code, 302, msg=ok.location)
return token
def _non_local_get(self, client, path: str):
return client.get(
path,
base_url="http://example.com",
environ_overrides={"REMOTE_ADDR": "10.0.0.55"},
follow_redirects=False,
)
def _access_link_payload(self):
resp = self.client.get("/api/kiosk/access-link")
self.assertEqual(resp.status_code, 200)
return resp.get_json()
def _start_path_from_access_link(self) -> str:
payload = self._access_link_payload()
start_url = payload.get("start_url") or ""
self.assertTrue(start_url.startswith("http"))
return start_url.split("/kiosk/start/", 1)[1]
def _pair_via_start_url(self, client=None):
client = client or self.client
slug = self._start_path_from_access_link()
return client.get(
f"/kiosk/start/{slug}",
base_url="http://example.com",
environ_overrides={"REMOTE_ADDR": "10.0.0.55"},
follow_redirects=False,
)
def test_access_link_denied_for_external_without_auth(self) -> None:
ext = self.app.test_client()
ext.get(
"/scales",
base_url="http://example.com",
environ_overrides={"REMOTE_ADDR": "10.0.0.55"},
follow_redirects=False,
)
resp = ext.get(
"/api/kiosk/access-link",
base_url="http://example.com",
environ_overrides={"REMOTE_ADDR": "10.0.0.55"},
)
self.assertEqual(resp.status_code, 403)
def test_setup_available_false_for_external(self) -> None:
resp = self.client.get(
"/api/kiosk/setup-available",
base_url="http://example.com",
environ_overrides={"REMOTE_ADDR": "10.0.0.55"},
)
self.assertEqual(resp.status_code, 200)
self.assertFalse(resp.get_json().get("can_issue_access_link"))
def test_setup_available_true_for_localhost(self) -> None:
resp = self.client.get("/api/kiosk/setup-available", base_url="http://localhost:5000")
self.assertTrue(resp.get_json().get("can_issue_access_link"))
def test_setup_available_true_with_admin_session(self) -> None:
with self.client.session_transaction() as sess:
sess["authenticated"] = True
resp = self.client.get("/api/kiosk/setup-available")
self.assertTrue(resp.get_json().get("can_issue_access_link"))
def test_access_link_allowed_with_admin_session(self) -> None:
with self.client.session_transaction() as sess:
sess["authenticated"] = True
resp = self.client.get("/api/kiosk/access-link")
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.get_json().get("start_url"))
def test_access_link_generates_permanent_slug(self) -> None:
first = self._access_link_payload()
second = self._access_link_payload()
self.assertTrue(first.get("permanent"))
self.assertEqual(first.get("start_url"), second.get("start_url"))
self.assertTrue(first["start_url"].endswith("/kiosk/start/" + first["start_url"].rsplit("/", 1)[-1]))
def test_access_link_refresh_rotates_slug(self) -> None:
first = self._access_link_payload()
old_slug = first["start_url"].rsplit("/", 1)[-1]
refreshed = self.client.post(
"/api/kiosk/access-link",
json={"refresh": True},
)
self.assertEqual(refreshed.status_code, 200)
new_url = refreshed.get_json().get("start_url") or ""
new_slug = new_url.rsplit("/", 1)[-1]
self.assertNotEqual(old_slug, new_slug)
stale = self._non_local_get(self.client, f"/kiosk/start/{old_slug}")
self.assertEqual(stale.status_code, 404)
def test_access_link_qr_image_is_local_svg_data_uri(self) -> None:
payload = self._access_link_payload()
uri = payload.get("qr_image_url") or ""
self.assertTrue(uri.startswith("data:image/svg+xml;base64,"))
raw = base64.b64decode(uri.split(",", 1)[1])
self.assertIn(b"<svg", raw.lower())
def test_start_url_sets_cookie_and_redirects(self) -> None:
resp = self._pair_via_start_url()
self.assertEqual(resp.status_code, 302)
self.assertIn("/scales", resp.headers.get("Location") or "")
self.assertIn("wesp_kiosk_device_id=", resp.headers.get("Set-Cookie") or "")
def test_fully_kiosk_flow_api_access(self) -> None:
fk = self.app.test_client()
start = self._pair_via_start_url(fk)
self.assertEqual(start.status_code, 302)
allowed = fk.get(
"/current_weight",
base_url="http://example.com",
environ_overrides={"REMOTE_ADDR": "10.0.0.55"},
)
self.assertEqual(allowed.status_code, 200)
def test_start_url_reopen_with_cookie(self) -> None:
fk = self.app.test_client()
slug = self._start_path_from_access_link()
first = fk.get(
f"/kiosk/start/{slug}",
base_url="http://example.com",
environ_overrides={"REMOTE_ADDR": "10.0.0.55"},
follow_redirects=False,
)
self.assertEqual(first.status_code, 302)
second = fk.get(
f"/kiosk/start/{slug}",
base_url="http://example.com",
environ_overrides={"REMOTE_ADDR": "10.0.0.55"},
follow_redirects=False,
)
self.assertEqual(second.status_code, 302)
def test_access_link_uses_configured_public_base_url(self) -> None:
self.app.config["KIOSK_PUBLIC_BASE_URL"] = "http://192.168.1.77:5000"
payload = self._access_link_payload()
self.assertTrue(payload["start_url"].startswith("http://192.168.1.77:5000/kiosk/start/"))
def test_access_link_localhost_uses_detected_lan_ip(self) -> None:
self.app.config["KIOSK_PUBLIC_BASE_URL"] = ""
with patch("app.routes.kiosk.detect_lan_ip", return_value="192.168.88.25"):
resp = self.client.get("/api/kiosk/access-link", base_url="http://localhost:5000")
self.assertEqual(resp.status_code, 200)
self.assertTrue(
resp.get_json()["start_url"].startswith("http://192.168.88.25:5000/kiosk/start/")
)
def test_unpaired_then_start_url_then_api_access(self) -> None:
status = self.client.get("/api/kiosk/status")
self.assertFalse(status.get_json().get("paired"))
blocked = self.client.get(
"/current_weight",
base_url="http://example.com",
environ_overrides={"REMOTE_ADDR": "10.0.0.55"},
)
self.assertEqual(blocked.status_code, 401)
fk = self.app.test_client()
self._pair_via_start_url(fk)
status2 = fk.get(
"/api/kiosk/status",
base_url="http://example.com",
environ_overrides={"REMOTE_ADDR": "10.0.0.55"},
)
self.assertTrue(status2.get_json().get("paired"))
allowed = fk.get(
"/current_weight",
base_url="http://example.com",
environ_overrides={"REMOTE_ADDR": "10.0.0.55"},
)
self.assertEqual(allowed.status_code, 200)
def test_unpaired_then_pair_then_api_access(self) -> None:
status = self.client.get("/api/kiosk/status")
self.assertEqual(status.status_code, 200)
body0 = status.get_json()
self.assertFalse(body0.get("paired"))
self.assertTrue(body0.get("enforce_paired_only"))
blocked = self.client.get(
"/current_weight",
base_url="http://example.com",
environ_overrides={"REMOTE_ADDR": "10.0.0.55"},
)
self.assertEqual(blocked.status_code, 401)
token = self._pair()
status2 = self.client.get("/api/kiosk/status")
self.assertTrue(status2.get_json().get("paired"))
allowed = self.client.get("/current_weight")
self.assertEqual(allowed.status_code, 200)
reused = self.client.get(f"/api/kiosk/pair/confirm?token={token}")
self.assertEqual(reused.status_code, 200)
self.assertIn("уже", reused.get_data(as_text=True).lower())
again = self._confirm_post(token)
self.assertEqual(again.status_code, 409)
def test_pair_token_qr_image_is_local_svg_data_uri(self) -> None:
pair = self.client.post("/api/kiosk/pair-token")
self.assertEqual(pair.status_code, 200)
payload = pair.get_json()
uri = payload.get("qr_image_url") or ""
self.assertTrue(uri.startswith("data:image/svg+xml;base64,"), msg=repr(uri[:100]))
self.assertNotIn("qrserver", uri)
raw = base64.b64decode(uri.split(",", 1)[1])
self.assertIn(b"<svg", raw.lower())
def test_pair_token_single_use(self) -> None:
token = self._pair()
reused_get = self.client.get(f"/api/kiosk/pair/confirm?token={token}")
self.assertEqual(reused_get.status_code, 200)
self.assertIn("уже", reused_get.get_data(as_text=True).lower())
reused_post = self._confirm_post(token)
self.assertEqual(reused_post.status_code, 409)
def test_localhost_bypass_for_kiosk_pages(self) -> None:
kiosk_paths = [
"/scales",
"/calibration",
"/db_check",
"/duplicate",
"/unloading",
]
for path in kiosk_paths:
resp = self.client.get(path, base_url="http://localhost:5000")
self.assertEqual(resp.status_code, 200, msg=f"localhost should bypass guard for {path}")
def test_localhost_bypass_for_protected_api(self) -> None:
weight_resp = self.client.get("/current_weight", base_url="http://localhost:5000")
self.assertEqual(weight_resp.status_code, 200)
sse_resp = self.client.get("/stream_weight?r=test", base_url="http://localhost:5000")
self.assertEqual(sse_resp.status_code, 200)
def test_unpaired_non_localhost_redirects_to_unauthorized(self) -> None:
external_client = self.app.test_client()
resp = self._non_local_get(external_client, "/scales")
self.assertEqual(resp.status_code, 302)
location = resp.headers.get("Location") or ""
self.assertIn("/unauthorized-device", location)
self.assertTrue(
"next=%2Fscales" in location or "next=/scales" in location,
msg=location,
)
self.assertIn("wesp_kiosk_device_id=", (resp.headers.get("Set-Cookie") or ""))
def test_confirm_post_activates_confirming_browser_when_different_from_token_device(
self,
) -> None:
"""Главный (localhost) выпускает токен; телефон с другим cookie подтверждает — оба active."""
source_terminal = self.client
external_client = self.app.test_client()
external_resp = self._non_local_get(external_client, "/scales")
self.assertEqual(external_resp.status_code, 302)
pair = source_terminal.post("/api/kiosk/pair-token")
self.assertEqual(pair.status_code, 200)
token = pair.get_json().get("token")
self.assertTrue(token)
ok = external_client.post(
"/api/kiosk/pair/confirm",
data={"token": token},
content_type="application/x-www-form-urlencoded",
base_url="http://example.com",
environ_overrides={"REMOTE_ADDR": "10.0.0.55"},
)
self.assertEqual(ok.status_code, 302)
allowed = external_client.get(
"/current_weight",
base_url="http://example.com",
environ_overrides={"REMOTE_ADDR": "10.0.0.55"},
)
self.assertEqual(allowed.status_code, 200)
def test_scan_confirm_pairs_external_device(self) -> None:
source_terminal = self.client
external_client = self.app.test_client()
external_resp = self._non_local_get(external_client, "/scales")
self.assertEqual(external_resp.status_code, 302)
pair = source_terminal.post("/api/kiosk/pair-token")
self.assertEqual(pair.status_code, 200)
token = pair.get_json().get("token")
self.assertTrue(token)
confirm = external_client.post(
"/api/kiosk/pair/scan-confirm",
json={"token": token},
base_url="http://example.com",
environ_overrides={"REMOTE_ADDR": "10.0.0.55"},
)
self.assertEqual(confirm.status_code, 200)
self.assertTrue(confirm.get_json().get("paired"))
allowed = external_client.get(
"/current_weight",
base_url="http://example.com",
environ_overrides={"REMOTE_ADDR": "10.0.0.55"},
)
self.assertEqual(allowed.status_code, 200)
def test_claim_link_pairs_external_device(self) -> None:
source_terminal = self.client
external_client = self.app.test_client()
external_resp = self._non_local_get(external_client, "/scales")
self.assertEqual(external_resp.status_code, 302)
pair = source_terminal.post("/api/kiosk/pair-token")
self.assertEqual(pair.status_code, 200)
token = pair.get_json().get("token")
self.assertTrue(token)
claim = external_client.get(
f"/api/kiosk/pair/claim?token={token}",
base_url="http://example.com",
environ_overrides={"REMOTE_ADDR": "10.0.0.55"},
)
self.assertEqual(claim.status_code, 200)
allowed = external_client.get(
"/current_weight",
base_url="http://example.com",
environ_overrides={"REMOTE_ADDR": "10.0.0.55"},
)
self.assertEqual(allowed.status_code, 200)
def test_pair_token_uses_configured_public_base_url(self) -> None:
self.app.config["KIOSK_PUBLIC_BASE_URL"] = "http://192.168.1.77:5000"
resp = self.client.post("/api/kiosk/pair-token", base_url="http://localhost:5000")
self.assertEqual(resp.status_code, 200)
payload = resp.get_json()
self.assertTrue(payload["pair_url"].startswith("http://192.168.1.77:5000/api/kiosk/pair/confirm?token="))
self.assertTrue(payload["confirm_url"].startswith("http://192.168.1.77:5000/api/kiosk/pair/confirm?token="))
def test_pair_token_localhost_uses_detected_lan_ip(self) -> None:
self.app.config["KIOSK_PUBLIC_BASE_URL"] = ""
with patch("app.routes.kiosk.detect_lan_ip", return_value="192.168.88.25"):
resp = self.client.post("/api/kiosk/pair-token", base_url="http://localhost:5000")
self.assertEqual(resp.status_code, 200)
payload = resp.get_json()
self.assertTrue(payload["pair_url"].startswith("http://192.168.88.25:5000/api/kiosk/pair/confirm?token="))
self.assertTrue(payload["confirm_url"].startswith("http://192.168.88.25:5000/api/kiosk/pair/confirm?token="))
def test_pair_token_localhost_falls_back_to_request_host(self) -> None:
self.app.config["KIOSK_PUBLIC_BASE_URL"] = ""
with patch("app.routes.kiosk.detect_lan_ip", return_value=""):
resp = self.client.post("/api/kiosk/pair-token", base_url="http://localhost:5000")
self.assertEqual(resp.status_code, 200)
payload = resp.get_json()
self.assertTrue(payload["pair_url"].startswith("http://localhost:5000/api/kiosk/pair/confirm?token="))
self.assertTrue(payload["confirm_url"].startswith("http://localhost:5000/api/kiosk/pair/confirm?token="))
class KioskEnforceOffConfig(KioskPairConfig):
KIOSK_ENFORCE_PAIRED_ONLY = False
class KioskPairingEnforceOffTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(KioskEnforceOffConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
self.client.get("/scales")
def tearDown(self) -> None:
from app.routes import scales as scales_module
reader = getattr(scales_module, "_scales_reader", None)
if reader is not None:
reader.stop()
scales_module._scales_reader = None
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_kiosk_status_includes_enforce_off(self) -> None:
st = self.client.get("/api/kiosk/status")
self.assertEqual(st.status_code, 200)
body = st.get_json()
self.assertIn("enforce_paired_only", body)
self.assertFalse(body.get("enforce_paired_only"))
def test_scales_page_not_redirected_when_enforce_off(self) -> None:
ext = self.app.test_client()
resp = ext.get(
"/scales",
base_url="http://kiosk.example:5000",
environ_overrides={"REMOTE_ADDR": "10.0.0.99"},
follow_redirects=False,
)
self.assertEqual(resp.status_code, 200)
if __name__ == "__main__":
unittest.main()
+137
View File
@@ -0,0 +1,137 @@
"""Доступ к модулю Lab — per-user lab_access и server-side guards."""
from __future__ import annotations
import unittest
from app import create_app, db
from app.models import Recipe, WebUser
from app.models.base import default_uuid
from app.services.setup_state import mark_setup_complete
from tests.helpers.zootech_test_helpers import ZootechTestConfig
from werkzeug.security import generate_password_hash
class LabAccessControlTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
recipe = Recipe(id=default_uuid(), name="Lab access test", heads_per_trip=10, mixing_time=5)
db.session.add(recipe)
db.session.add(
WebUser(
login="zootech-test-admin",
password_hash=generate_password_hash("zootech-test-secret"),
is_superuser=True,
)
)
db.session.add(
WebUser(
login="zootech-no-lab",
password_hash=generate_password_hash("zootech-no-lab-secret"),
is_superuser=False,
lab_access=False,
)
)
db.session.add(
WebUser(
login="zootech-with-lab",
password_hash=generate_password_hash("zootech-with-lab-secret"),
is_superuser=False,
lab_access=True,
)
)
db.session.commit()
self.recipe_id = recipe.id
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def _login(self, login: str, password: str) -> None:
r = self.client.post("/api/auth/login", json={"login": login, "password": password})
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
def test_auth_check_can_lab_for_superuser(self) -> None:
self._login("zootech-test-admin", "zootech-test-secret")
body = self.client.get("/api/auth/check").get_json()
self.assertTrue(body.get("can_lab"))
def test_auth_check_can_lab_disabled_for_zootech(self) -> None:
self._login("zootech-no-lab", "zootech-no-lab-secret")
body = self.client.get("/api/auth/check").get_json()
self.assertFalse(body.get("can_lab"))
def test_auth_check_can_lab_enabled_for_zootech(self) -> None:
self._login("zootech-with-lab", "zootech-with-lab-secret")
body = self.client.get("/api/auth/check").get_json()
self.assertTrue(body.get("can_lab"))
def test_lab_page_redirect_without_access(self) -> None:
self._login("zootech-no-lab", "zootech-no-lab-secret")
r = self.client.get("/lab", follow_redirects=False)
self.assertEqual(r.status_code, 302)
self.assertIn("/recipes", r.headers.get("Location", ""))
def test_lab_page_ok_with_access(self) -> None:
self._login("zootech-with-lab", "zootech-with-lab-secret")
r = self.client.get("/lab")
self.assertEqual(r.status_code, 200)
def test_lab_api_forbidden_without_access(self) -> None:
self._login("zootech-no-lab", "zootech-no-lab-secret")
r = self.client.get("/api/lab/recipes")
self.assertEqual(r.status_code, 403, r.get_data(as_text=True))
def test_lab_api_ok_with_access(self) -> None:
self._login("zootech-with-lab", "zootech-with-lab-secret")
r = self.client.get("/api/lab/recipes")
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
def test_components_nutrients_hidden_without_lab_access(self) -> None:
self._login("zootech-no-lab", "zootech-no-lab-secret")
create = self.client.post(
"/api/components",
json={
"name": "Сено",
"type": "Грубые корма",
"dry_matter": 88,
"price": 10,
"nutrients": {"Сырая зола": 50},
},
)
self.assertEqual(create.status_code, 403, create.get_data(as_text=True))
create_ok = self.client.post(
"/api/components",
json={"name": "Сено", "type": "Грубые корма", "dry_matter": 88, "price": 10},
)
self.assertEqual(create_ok.status_code, 201, create_ok.get_data(as_text=True))
comp_id = create_ok.get_json()["id"]
get_one = self.client.get(f"/api/components/{comp_id}").get_json()
self.assertEqual(get_one.get("nutrients"), {})
def test_admin_can_toggle_lab_access(self) -> None:
self._login("zootech-test-admin", "zootech-test-secret")
user = db.session.query(WebUser).filter_by(login="zootech-no-lab").one()
patch = self.client.patch(
f"/api/admin/users/{user.id}",
json={"lab_access": True},
)
self.assertEqual(patch.status_code, 200, patch.get_data(as_text=True))
db.session.refresh(user)
self.assertTrue(user.lab_access)
self.client.post("/api/auth/logout")
self._login("zootech-no-lab", "zootech-no-lab-secret")
r = self.client.get("/api/lab/recipes")
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
if __name__ == "__main__":
unittest.main()
+221
View File
@@ -0,0 +1,221 @@
"""AgroStar XML import — parser, matching, API."""
from __future__ import annotations
import unittest
from pathlib import Path
from app import create_app, db
from app.lab.etl.agrostar_pdf_import import parse_agrostar_pdf
from app.lab.etl.agrostar_xml_import import (
apply_agrostar_import,
build_sample_preview,
build_storage_report,
enrich_parse_with_matches,
parse_agrostar_xml,
suggest_canonical_feed_type,
suggest_component_matches,
)
from app.models.base import default_uuid
from app.models.component import Component
from app.services.setup_state import mark_setup_complete
from tests.helpers.zootech_test_helpers import ZootechTestConfig
_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "lab" / "agrostar_yama5.xml"
_REF = Path(__file__).resolve().parents[3] / "для разбора и внедрения "
class AgrostarXmlImportTests(unittest.TestCase):
def test_parse_yama5_fixture(self) -> None:
text = _FIXTURE.read_text(encoding="utf-8")
result = parse_agrostar_xml(text)
self.assertFalse(result.errors, result.errors)
self.assertEqual(result.lab_name, "АгроСтар")
self.assertEqual(len(result.samples), 1)
sample = result.samples[0]
self.assertEqual(sample.sample_no, "471505270426")
self.assertAlmostEqual(sample.dry_matter_pct or 0, 25.06, places=2)
self.assertAlmostEqual(sample.nutrients["Сыр. Протеин"], 107.5, places=1)
self.assertAlmostEqual(sample.nutrients["Сырая клетч"], 645.5, places=1)
self.assertAlmostEqual(sample.nutrients["ВРХ Орг Вещ"], 53.01, places=2)
self.assertIn("omd_from_tdn", sample.warnings)
def test_build_sample_preview(self) -> None:
text = _FIXTURE.read_text(encoding="utf-8")
sample = parse_agrostar_xml(text).samples[0]
preview = build_sample_preview(sample)
self.assertIn("Смешанный сенаж", preview["feedTypeRu"])
self.assertGreaterEqual(preview["recognizedCount"], 10)
labels = [row["label"] for row in preview["willWrite"]]
self.assertIn("Сухое вещество (поле компонента)", labels)
self.assertIn("Сырой протеин", labels)
protein = next(row for row in preview["willWrite"] if row["label"] == "Сырой протеин")
self.assertEqual(protein["sourceValue"], "10.75")
self.assertEqual(protein["sourceUnit"], "%DM")
self.assertEqual(protein["value"], "107.5")
self.assertTrue(any("TDN" in n or "ВРХ" in n for n in preview["notes"]))
@unittest.skipUnless(_REF.is_dir(), "reference folder missing")
def test_pdf_structural_fiber_from_andfom(self) -> None:
pdf = next(_REF.glob("5LDBW*.pdf"), None)
if pdf is None:
self.skipTest("no agrostar pdf")
sample = parse_agrostar_pdf(pdf.read_bytes()).samples[0]
self.assertAlmostEqual(sample.nutrients["КДК"], 444.4, places=1)
self.assertAlmostEqual(sample.nutrients["Структур. клетч"], 621.8, places=1)
preview = build_sample_preview(sample)
struct = next(row for row in preview["willWrite"] if row["label"] == "Структурная клетчатка")
self.assertEqual(struct["sourceValue"], "62.18")
self.assertEqual(struct["value"], "621.8")
def test_suggest_canonical_feed_type(self) -> None:
text = _FIXTURE.read_text(encoding="utf-8")
sample = parse_agrostar_xml(text).samples[0]
self.assertEqual(suggest_canonical_feed_type(sample), "Сочные корма")
self.assertEqual(build_sample_preview(sample)["suggestedType"], "Сочные корма")
def test_storage_report_flags_unsupported(self) -> None:
text = _FIXTURE.read_text(encoding="utf-8")
sample = parse_agrostar_xml(text).samples[0]
report = build_storage_report(sample)
self.assertGreaterEqual(report["nutrientCount"], 15)
self.assertTrue(any(u["agroKey"] == "NDFDom_IV_30hr" for u in report["unsupported"]))
self.assertIn("переваримость NDF", report["unsupportedMessage"])
self.assertIn("останутся только в AgroStar", report["unsupportedMessage"])
def test_enrich_parse_includes_preview(self) -> None:
app = create_app(ZootechTestConfig)
with app.app_context():
db.create_all()
text = _FIXTURE.read_text(encoding="utf-8")
body = enrich_parse_with_matches(parse_agrostar_xml(text))
self.assertIn("preview", body["samples"][0])
self.assertTrue(body["samples"][0]["preview"]["willWrite"])
db.drop_all()
def test_parse_invalid_xml(self) -> None:
result = parse_agrostar_xml("<not>valid")
self.assertTrue(result.errors)
self.assertEqual(len(result.samples), 0)
def test_suggest_matches_silos(self) -> None:
app = create_app(ZootechTestConfig)
with app.app_context():
db.create_all()
comp = Component(
id=default_uuid(),
name="Силос ПЗ Мельниково Тр №5. СВ-251",
type="Сочные",
dry_matter=25.0,
is_active=True,
)
db.session.add(comp)
db.session.commit()
matches = suggest_component_matches("Силос разнотравье яма 5 закрытая")
self.assertTrue(matches)
self.assertGreater(matches[0].score, 0.25)
db.drop_all()
class AgrostarImportApiTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
self.comp = Component(
id=default_uuid(),
name="Силос яма 5 тест",
type="Сочные",
dry_matter=20.0,
is_active=True,
)
db.session.add(self.comp)
db.session.commit()
self.client.post(
"/api/auth/login",
json={"login": "zootech-test-admin", "password": "zootech-test-secret"},
)
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_parse_api_json(self) -> None:
xml = _FIXTURE.read_text(encoding="utf-8")
r = self.client.post("/api/lab/import/agrostar-xml", json={"xml": xml})
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
body = r.get_json()
self.assertEqual(body["sampleCount"], 1)
self.assertTrue(body["samples"][0]["nutrients"])
self.assertIn("willWrite", body["samples"][0]["preview"])
def test_parse_api_multipart(self) -> None:
with _FIXTURE.open("rb") as fh:
r = self.client.post(
"/api/lab/import/agrostar-xml",
data={"file": (fh, "sample.xml")},
content_type="multipart/form-data",
)
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
def test_create_component_from_agrostar_sample(self) -> None:
xml = _FIXTURE.read_text(encoding="utf-8")
sample = parse_agrostar_xml(xml).samples[0]
preview = build_sample_preview(sample)
r = self.client.post(
"/api/components",
json={
"name": preview["suggestedName"],
"type": preview["suggestedType"],
"dry_matter": sample.dry_matter_pct,
"nutrients": sample.nutrients,
},
)
self.assertEqual(r.status_code, 201, r.get_data(as_text=True))
cid = r.get_json()["id"]
one = self.client.get(f"/api/components/{cid}")
self.assertEqual(one.status_code, 200)
body = one.get_json()
self.assertAlmostEqual(body["dryMatter"], 25.06, places=2)
self.assertAlmostEqual(body["nutrients"]["Сыр. Протеин"], 107.5, places=1)
self.assertAlmostEqual(body["nutrients"]["Лизин"], 4.4, places=1)
self.assertAlmostEqual(body["nutrients"]["Ca"], 7.3, places=1)
def test_apply_dry_run_and_write(self) -> None:
xml = _FIXTURE.read_text(encoding="utf-8")
parsed = parse_agrostar_xml(xml)
sample = parsed.samples[0]
assignment = {
"sampleNo": sample.sample_no,
"componentId": self.comp.id,
"dryMatterPct": sample.dry_matter_pct,
"nutrients": sample.nutrients,
"warnings": sample.warnings,
}
r = self.client.post(
"/api/lab/import/agrostar-xml/apply",
json={"assignments": [assignment], "dryRun": True},
)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.get_json()["applied"], 1)
r2 = self.client.post(
"/api/lab/import/agrostar-xml/apply",
json={"assignments": [assignment], "dryRun": False},
)
self.assertEqual(r2.status_code, 200)
db.session.refresh(self.comp)
self.assertAlmostEqual(self.comp.dry_matter, 25.06, places=2)
from app.lab.services.component_nutrients import nutrients_api_dict
nutrients = nutrients_api_dict(self.comp.id)
self.assertAlmostEqual(nutrients["Сыр. Протеин"], 107.5, places=1)
if __name__ == "__main__":
unittest.main()
+85
View File
@@ -0,0 +1,85 @@
"""API /api/lab/"""
from __future__ import annotations
import unittest
from app import create_app, db
from app.models import Recipe
from app.models.base import default_uuid
from app.services.setup_state import mark_setup_complete
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class LabApiTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
recipe = Recipe(id=default_uuid(), name="Lab test", heads_per_trip=10, mixing_time=5)
db.session.add(recipe)
db.session.commit()
self.recipe_id = recipe.id
self.client.post(
"/api/auth/login",
json={"login": "zootech-test-admin", "password": "zootech-test-secret"},
)
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_health(self) -> None:
r = self.client.get("/api/lab/health")
self.assertEqual(r.status_code, 200)
self.assertTrue(r.get_json()["ok"])
def test_lab_recipes_list_with_session(self) -> None:
"""Селект партий в lab — /api/lab/recipes, контур зоотехника (не paired terminal)."""
r = self.client.get("/api/lab/recipes")
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
recipes = r.get_json()["recipes"]
ids = {row["id"] for row in recipes}
self.assertIn(self.recipe_id, ids)
def test_seed_and_get_ration(self) -> None:
r = self.client.post(f"/api/lab/rations/{self.recipe_id}/seed-from-execution")
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
self.assertTrue(r.get_json()["seeded"])
r2 = self.client.get(f"/api/lab/rations/{self.recipe_id}")
self.assertEqual(r2.status_code, 200)
body = r2.get_json()
self.assertEqual(body["recipeId"], self.recipe_id)
self.assertTrue(body["exists"])
def test_ensure_empty_and_sync_from_execution(self) -> None:
r = self.client.post(f"/api/lab/rations/{self.recipe_id}/ensure-empty")
self.assertEqual(r.status_code, 200)
self.assertTrue(r.get_json()["created"])
r2 = self.client.post(f"/api/lab/rations/{self.recipe_id}/sync-from-execution")
self.assertEqual(r2.status_code, 200)
self.assertGreaterEqual(r2.get_json()["lines"], 0)
def test_animal_profile_crud(self) -> None:
r = self.client.post(
"/api/lab/animal-profiles",
json={
"profileKey": "test_beef",
"label": "Тест",
"rationType": "BEEF",
"normsData": {"dry_matter": {"min": 1, "max": 2}},
},
)
self.assertEqual(r.status_code, 201, r.get_data(as_text=True))
pid = r.get_json()["id"]
r2 = self.client.get(f"/api/lab/animal-profiles/{pid}")
self.assertEqual(r2.status_code, 200)
self.assertEqual(r2.get_json()["profileKey"], "test_beef")
def test_print_requires_master(self) -> None:
r = self.client.get(f"/api/lab/rations/{self.recipe_id}/print?mode=ration")
self.assertEqual(r.status_code, 404)
@@ -0,0 +1,86 @@
"""apply_from_master ставит execution в sync_queue (не lab_*)."""
from __future__ import annotations
import json
import unittest
from sqlalchemy import func, select
from app import create_app, db
from app.lab.commands import apply_from_master, seed_from_execution
from app.lab.models import LabRationLine
from app.models import Component, Ingredient, Recipe, SyncQueue
from app.models.base import default_uuid
from app.services.setup_state import mark_setup_complete
from tests.helpers.zootech_test_helpers import ZootechTestConfig
class LabApplySyncEnqueueTests(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(ZootechTestConfig)
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
mark_setup_complete(self.app)
self.comp = Component(
id=default_uuid(),
name="Сено",
type="Объемные корма",
dry_matter=85.0,
protein=10.0,
energy=8.0,
price=5.0,
)
self.recipe = Recipe(id=default_uuid(), name="Sync test", heads_per_trip=10, mixing_time=5)
self.ingredient = Ingredient(
id=default_uuid(),
recipe_id=self.recipe.id,
component_id=self.comp.id,
name="Сено",
amount=5.0,
weight_per_head=5.0,
order=0,
)
db.session.add_all([self.comp, self.recipe, self.ingredient])
db.session.commit()
seed_from_execution(self.recipe.id)
line = LabRationLine.query.filter_by(recipe_id=self.recipe.id).first()
line.daily_kg = 40.0
db.session.commit()
def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_apply_from_master_enqueues_ingredient_not_lab(self) -> None:
before_lab = int(
db.session.scalar(
select(func.count()).select_from(SyncQueue).where(
SyncQueue.table_name.like("lab_%")
)
)
or 0
)
apply_from_master(self.recipe.id)
after_ing = int(
db.session.scalar(
select(func.count()).select_from(SyncQueue).where(
SyncQueue.table_name == "ingredient",
SyncQueue.record_id == self.ingredient.id,
)
)
or 0
)
after_lab = int(
db.session.scalar(
select(func.count()).select_from(SyncQueue).where(
SyncQueue.table_name.like("lab_%")
)
)
or 0
)
self.assertGreater(after_ing, 0)
self.assertEqual(before_lab, after_lab)
@@ -0,0 +1,38 @@
"""Суточные итоги на голову (parity Excel Рацион КРС)."""
from app.lab.calc.engine import calculate_ration
def test_daily_total_scales_by_heads():
lines = [
{
"daily_kg": 141,
"in_ration": True,
"in_compound": False,
"dry_matter": 880,
"nutrients": {},
}
]
herd = calculate_ration("BEEF", lines, {}, heads_per_trip=141)
per_head = calculate_ration("BEEF", [{**lines[0], "daily_kg": 1}], {}, heads_per_trip=1)
herd_dm = next(i["content"] for i in herd["indicators"] if i["label"] == "Сухое вещество")
head_dm = next(i["content"] for i in per_head["indicators"] if i["label"] == "Сухое вещество")
assert herd_dm == head_dm == 880
def test_sv_from_nutrients_overrides_legacy_percent():
result = calculate_ration(
"BEEF",
[
{
"daily_kg": 10,
"in_ration": True,
"in_compound": False,
"dry_matter": 56,
"nutrients": {"СВ": 880},
}
],
{},
)
dm = next(i["content"] for i in result["indicators"] if i["label"] == "Сухое вещество")
assert dm == 8800
+60
View File
@@ -0,0 +1,60 @@
"""Доп. тесты calc engine (пустой рацион, compound)."""
from __future__ import annotations
import unittest
from app.lab.calc.engine import calculate_ration
class LabCalcEngineTests(unittest.TestCase):
def test_empty_lines_returns_errors_or_totals(self) -> None:
result = calculate_ration("BEEF", [], {})
self.assertIn("totals", result)
self.assertIn("indicators", result)
def test_dairy_ration_type(self) -> None:
result = calculate_ration(
"DAIRY",
[
{
"daily_kg": 50,
"in_ration": True,
"in_compound": True,
"dry_matter": 850,
"nutrients": {"Сыр. Протеин": 100},
}
],
{},
)
total = next(t["value"] for t in result["totals"] if t["key"] == "total_kg")
self.assertEqual(total, 50)
def test_bra_derived_from_protein_and_usp(self) -> None:
result = calculate_ration(
"DAIRY",
[
{
"daily_kg": 10,
"in_ration": True,
"in_compound": False,
"dry_matter": 850,
"nutrients": {"Сыр. Протеин": 100, "уСП": 80},
},
{
"daily_kg": 5,
"in_ration": True,
"in_compound": False,
"dry_matter": 880,
"nutrients": {"Сыр. Протеин": 200, "уСП": 160},
},
],
{},
heads_per_trip=1,
)
cp = next(i["content"] for i in result["indicators"] if i["key"] == "crude_protein")
usp = next(i["content"] for i in result["indicators"] if i["key"] == "usp")
rnb = next(i["content"] for i in result["indicators"] if i["key"] == "rnb")
self.assertAlmostEqual(cp, 10 * 100 + 5 * 200)
self.assertAlmostEqual(usp, 10 * 80 + 5 * 160)
self.assertAlmostEqual(rnb, (cp - usp) / 6.25)
+35
View File
@@ -0,0 +1,35 @@
"""Golden parity with tab/packages/calc-engine/src/native/golden-test.ts"""
from app.lab.calc.engine import calculate_ration
def test_beef_ration_total_and_compound():
result = calculate_ration(
"BEEF",
[
{
"daily_kg": 80,
"in_ration": True,
"in_compound": True,
"ingredient_name": "Тест",
"price_per_kg": 10,
"dry_matter": 880,
"nutrients": {"Сыр. Протеин": 120, "ОЭ-КРС": 6.5, " ЧЭЛ- КРС": 7.2},
},
{
"daily_kg": 20,
"in_ration": True,
"in_compound": False,
"price_per_kg": 5,
"dry_matter": 900,
"nutrients": {"Сыр. Протеин": 100},
},
],
{"dry_matter": {"min": 4000, "max": 5000}},
)
total = next(t["value"] for t in result["totals"] if t["key"] == "total_kg")
assert total == 100
assert result["compound"] is not None
assert result["compound"]["totals"][0]["value"] == 80
cp = next(i for i in result["indicators"] if "Сырой протеин" in i["label"])
assert cp["content"] is not None and cp["content"] >= 11000

Some files were not shown because too many files have changed in this diff Show More