@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Браузерные E2E-тесты WESP."""
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"id": "prof-beef-001",
|
||||
"key": "beef_grow_400",
|
||||
"label": "Откорм 400 кг",
|
||||
"type": "BEEF",
|
||||
"norms_data": {"dry_matter": {"min": 4000, "max": 5000}}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{
|
||||
"id": "tab-ing-1",
|
||||
"external_no": 1001,
|
||||
"name": "Ячмень",
|
||||
"nutrients": {"Сыр. Протеин": 115, "СВ": 860}
|
||||
}
|
||||
]
|
||||
+101
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
# Minimal blocklist for pytest (login/password validation).
|
||||
password
|
||||
12345678
|
||||
qwerty
|
||||
admin123
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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§ion=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§ion=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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
Reference in New Issue
Block a user