@@ -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()
|
||||
Reference in New Issue
Block a user