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

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+1
View File
@@ -0,0 +1 @@
"""Общие хелперы для тестов WESP."""
@@ -0,0 +1,133 @@
"""Хелперы: мастер-рацион vs план на день vs экран оператора загрузки."""
from __future__ import annotations
from datetime import date
from typing import Any
from flask.testing import FlaskClient
DAILY_PLAN_SYNC_TABLES: tuple[str, ...] = (
"daily_trip_skip",
"daily_ingredient_skip",
"daily_unloading_group_skip",
"daily_ingredient_replacement",
"daily_component_norm_adjustment",
)
KIOSK_HEADERS = {"X-Wesp-Kiosk": "1"}
def plan_date_today() -> str:
return date.today().isoformat()
def master_recipe_json(client: FlaskClient, recipe_id: str) -> dict[str, Any]:
resp = client.get(f"/api/recipes/{recipe_id}")
assert resp.status_code == 200, resp.get_data(as_text=True)
return resp.get_json()
def plan_recipe_json(
client: FlaskClient,
recipe_id: str,
*,
plan_date: str | None = None,
kiosk: bool = True,
) -> dict[str, Any]:
iso = (plan_date or plan_date_today())[:10]
headers = KIOSK_HEADERS if kiosk else {}
resp = client.get(
f"/api/recipes/{recipe_id}?date={iso}",
headers=headers,
)
assert resp.status_code == 200, resp.get_data(as_text=True)
return resp.get_json()
def first_ingredient_amount(payload: dict[str, Any]) -> float:
ingredients = payload.get("ingredients") or []
assert ingredients, "expected at least one ingredient"
return float(ingredients[0].get("amount") or 0)
def ingredient_amounts(payload: dict[str, Any]) -> list[float]:
return [float(i.get("amount") or 0) for i in (payload.get("ingredients") or [])]
def operator_weight_display(client: FlaskClient, recipe_id: str) -> dict[str, Any]:
set_resp = client.post("/api/set_current_recipe", json={"recipe_id": recipe_id})
assert set_resp.status_code == 200, set_resp.get_data(as_text=True)
display = client.get("/api/weight_display_data")
assert display.status_code == 200, display.get_data(as_text=True)
return display.get_json()
def assert_operator_matches_plan_not_master(
client: FlaskClient,
recipe_id: str,
*,
plan_date: str | None = None,
expect_plan_differs_from_master: bool = True,
) -> dict[str, Any]:
"""Оператор (weight_display) = план; мастер — базовый рецепт без date=."""
master = master_recipe_json(client, recipe_id)
plan = plan_recipe_json(client, recipe_id, plan_date=plan_date)
operator = operator_weight_display(client, recipe_id)
assert operator.get("status") == "active", operator
plan_amounts = ingredient_amounts(plan)
master_amounts = ingredient_amounts(master)
assert plan_amounts, "plan must expose ingredients"
if expect_plan_differs_from_master:
assert plan_amounts != master_amounts or plan.get("ingredients") != master.get(
"ingredients"
), "plan should differ from master for this scenario"
assert operator["total_component"] == int(round(plan_amounts[0]))
assert operator["total_mixture"] == int(round(sum(plan_amounts)))
assert operator["total_component"] != int(round(master_amounts[0])) or len(
plan_amounts
) != len(master_amounts)
return {
"master": master,
"plan": plan,
"operator": operator,
"master_amounts": master_amounts,
"plan_amounts": plan_amounts,
}
def assert_daily_plan_tables_registered() -> None:
from app.models import (
DailyComponentNormAdjustment,
DailyIngredientReplacement,
DailyIngredientSkip,
DailyTripSkip,
DailyUnloadingGroupSkip,
_sync_models,
)
from app.services.sync_manager import SERVER_MASTER_TABLES, SNAPSHOT_MODELS
from app.services.sync_record_data import get_record_data_for_sync
model_by_table = {
DailyTripSkip.__tablename__: DailyTripSkip,
DailyIngredientSkip.__tablename__: DailyIngredientSkip,
DailyUnloadingGroupSkip.__tablename__: DailyUnloadingGroupSkip,
DailyIngredientReplacement.__tablename__: DailyIngredientReplacement,
DailyComponentNormAdjustment.__tablename__: DailyComponentNormAdjustment,
}
assert set(DAILY_PLAN_SYNC_TABLES) == set(model_by_table)
sync_model_tables = {m.__tablename__ for m in _sync_models if m.__tablename__.startswith("daily_")}
assert sync_model_tables == set(DAILY_PLAN_SYNC_TABLES)
snapshot_tables = {m.__tablename__ for m in SNAPSHOT_MODELS}
for table in DAILY_PLAN_SYNC_TABLES:
assert table in snapshot_tables, f"{table} missing from SNAPSHOT_MODELS"
assert table in SERVER_MASTER_TABLES, f"{table} missing from SERVER_MASTER_TABLES"
# smoke: get_record_data_for_sync resolves model (None без строки — ок)
assert get_record_data_for_sync("daily_trip_skip", "nonexistent-id") is None
@@ -0,0 +1,161 @@
"""Фабрики данных для тестов кормораздатчика (period_recipes)."""
from __future__ import annotations
from datetime import datetime
from typing import List, Tuple
from sqlalchemy import select
from app import db
from app.models import (
Component,
FeedDispenser,
FeedingPeriod,
Ingredient,
PeriodRecipe,
Recipe,
UnloadingGroup,
)
E2E_DISP_ID = "e2e-disp-1"
E2E_PERIOD_A = "e2e-period-a"
E2E_PERIOD_B = "e2e-period-b"
E2E_DISP_RECIPE_1 = "e2e-disp-recipe-1"
E2E_DISP_RECIPE_2 = "e2e-disp-recipe-2"
E2E_DISP_COMP = "e2e-disp-comp"
def seed_dispenser_period_recipes() -> Tuple[str, str, List[str]]:
"""Кормораздатчик + 2 периода + 2 рецепта в period A. Возвращает (disp_id, period_a_id, recipe_ids)."""
now = datetime.now()
db.session.add_all(
[
FeedDispenser(
id=E2E_DISP_ID,
name="E2E Раздатчик",
farm="Ферма",
operator="Тест",
type="dispenser",
content_hash="",
),
FeedingPeriod(id=E2E_PERIOD_A, name="E2E Утро", dispenser_id=E2E_DISP_ID),
FeedingPeriod(id=E2E_PERIOD_B, name="E2E Вечер", dispenser_id=E2E_DISP_ID),
Component(
id=E2E_DISP_COMP,
name="E2E Disp компонент",
type="grain",
dry_matter=55.0,
protein=0.0,
energy=0.0,
price=0.0,
),
Recipe(
id=E2E_DISP_RECIPE_1,
name="E2E Рейс 1",
heads_per_trip=20,
mixing_time=3,
trip_percent=100.0,
target_component_id=E2E_DISP_COMP,
content_hash="",
),
Recipe(
id=E2E_DISP_RECIPE_2,
name="E2E Рейс 2",
heads_per_trip=15,
mixing_time=4,
trip_percent=100.0,
target_component_id=E2E_DISP_COMP,
content_hash="",
),
]
)
db.session.flush()
for idx, (rid, ing_id) in enumerate(
((E2E_DISP_RECIPE_1, "e2e-disp-ing-1"), (E2E_DISP_RECIPE_2, "e2e-disp-ing-2")),
1,
):
db.session.add(
Ingredient(
id=ing_id,
name=f"Ing {idx}",
weight_per_head=float(idx),
amount=float(idx * 10),
dry_matter=55.0,
component_id=E2E_DISP_COMP,
order=1,
recipe_id=rid,
created_by="system",
updated_by="system",
)
)
db.session.add(
UnloadingGroup(
id=f"e2e-disp-grp-{idx}",
name=f"Г{idx}",
distribution_type="percent",
value=100.0,
weight=10.0,
order=1,
recipe_id=rid,
created_by="system",
updated_by="system",
)
)
for ord_, rid in enumerate((E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2), 0):
db.session.add(
PeriodRecipe(
period_id=E2E_PERIOD_A,
recipe_id=rid,
order=ord_,
created_at=now,
created_by="system",
updated_by="system",
)
)
db.session.commit()
return E2E_DISP_ID, E2E_PERIOD_A, [E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2]
def reset_e2e_dispenser_period_state() -> None:
"""Восстановить рейсы 1–2 в period A после мутирующих E2E (перенос, удаление, порядок)."""
now = datetime.now()
period_b_rows = db.session.execute(
select(PeriodRecipe).where(
PeriodRecipe.period_id == E2E_PERIOD_B,
PeriodRecipe.is_deleted.is_(False),
)
).scalars().all()
for row in period_b_rows:
db.session.delete(row)
db.session.flush()
for rid in (E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2):
row = db.session.get(PeriodRecipe, {"period_id": E2E_PERIOD_A, "recipe_id": rid})
if row is not None:
db.session.delete(row)
db.session.flush()
for ord_, rid in enumerate((E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2)):
db.session.add(
PeriodRecipe(
period_id=E2E_PERIOD_A,
recipe_id=rid,
order=ord_,
created_at=now,
updated_at=now,
created_by="system",
updated_by="system",
)
)
db.session.commit()
def get_period_recipe_order(period_id: str) -> List[str]:
rows = db.session.execute(
select(PeriodRecipe.recipe_id)
.where(
PeriodRecipe.period_id == period_id,
PeriodRecipe.is_deleted.is_(False),
)
.order_by(PeriodRecipe.order.asc())
).scalars().all()
return [str(x) for x in rows]
@@ -0,0 +1,42 @@
"""Helpers for lab module tests."""
from __future__ import annotations
import json
from pathlib import Path
from app import db
from app.lab.services.component_nutrients import upsert_from_api_dict
from app.models import Component
from app.models.base import default_uuid
FIXTURES_DIR = Path(__file__).resolve().parent.parent / "fixtures" / "lab"
def load_fixture(name: str) -> list[dict]:
path = FIXTURES_DIR / name
return json.loads(path.read_text(encoding="utf-8"))
def make_component(
*,
name: str = "Ячмень",
external_no: int | None = 1001,
dry_matter: float = 86.0,
nutrients: dict | None = None,
) -> Component:
comp = Component(
id=default_uuid(),
name=name,
type="Зерновые",
dry_matter=dry_matter,
protein=10.0,
energy=12.0,
price=15.0,
external_no=external_no,
)
db.session.add(comp)
db.session.flush()
upsert_from_api_dict(comp.id, nutrients or {"Сыр. Протеин": 115})
db.session.flush()
return comp
@@ -0,0 +1,174 @@
"""Фабрики данных для тестов кормоцеха и рецептов."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import func, select
from app import db
from app.models import Component, Ingredient, Recipe, SyncQueue, UnloadingGroup
def create_component(*, name: str = "Компонент А", dry_matter: float = 50.0) -> str:
comp_id = str(uuid.uuid4())
db.session.add(
Component(
id=comp_id,
name=name,
type="grain",
dry_matter=dry_matter,
protein=0.0,
energy=0.0,
price=0.0,
)
)
db.session.flush()
return comp_id
def create_recipe_with_children(
*,
name: str = "Тестовый рецепт",
heads: int = 10,
component_ids: Optional[List[str]] = None,
with_groups: bool = True,
) -> Tuple[str, List[str], List[str]]:
"""Рецепт + ингредиенты (+ опционально группы). Возвращает recipe_id, ingredient_ids, group_ids."""
if component_ids is None:
component_ids = [create_component(name=f"К{i + 1}") for i in range(2)]
recipe_id = str(uuid.uuid4())
recipe = Recipe(
id=recipe_id,
name=name,
heads_per_trip=heads,
mixing_time=5,
trip_percent=100.0,
target_component_id=component_ids[0],
content_hash="",
)
db.session.add(recipe)
db.session.flush()
ingredient_ids: List[str] = []
for idx, comp_id in enumerate(component_ids, 1):
ing = Ingredient(
name=f"Ing {idx}",
weight_per_head=float(idx),
amount=float(idx * heads),
dry_matter=50.0 + idx,
component_id=comp_id,
order=idx,
recipe_id=recipe_id,
created_by="system",
updated_by="system",
)
db.session.add(ing)
db.session.flush()
ingredient_ids.append(str(ing.id))
group_ids: List[str] = []
if with_groups:
for idx in range(1, 3):
grp = UnloadingGroup(
name=f"Г{idx}",
distribution_type="percent",
value=50.0,
weight=float(idx * 10),
order=idx,
recipe_id=recipe_id,
created_by="system",
updated_by="system",
)
db.session.add(grp)
db.session.flush()
group_ids.append(str(grp.id))
db.session.commit()
return recipe_id, ingredient_ids, group_ids
def recipe_update_payload(
recipe: Recipe,
*,
ingredients: List[Dict[str, Any]],
groups: Optional[List[Dict[str, Any]]] = None,
deleted_ingredient_ids: Optional[List[str]] = None,
deleted_group_ids: Optional[List[str]] = None,
target_component_id: Optional[str] = None,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"name": recipe.name,
"heads_count": recipe.heads_per_trip,
"mixing_time": recipe.mixing_time,
"trip_percent": recipe.trip_percent,
"target_component_id": target_component_id or recipe.target_component_id,
"dry_matter_locked": False,
"unloading_link_broken": False,
"ingredients": ingredients,
"unloading_groups": groups or [],
}
if deleted_ingredient_ids:
payload["deleted_ingredient_ids"] = deleted_ingredient_ids
if deleted_group_ids:
payload["deleted_unloading_group_ids"] = deleted_group_ids
return payload
def sync_task_exists(
table_name: str,
record_id: str,
action: str,
) -> bool:
row = db.session.execute(
select(SyncQueue).where(
SyncQueue.table_name == table_name,
SyncQueue.record_id == record_id,
SyncQueue.action == action,
)
).scalar_one_or_none()
return row is not None
def count_sync_tasks(
table_name: str,
record_id: str,
action: str,
) -> int:
return int(
db.session.scalar(
select(func.count())
.select_from(SyncQueue)
.where(
SyncQueue.table_name == table_name,
SyncQueue.record_id == record_id,
SyncQueue.action == action,
)
)
or 0
)
def ingredient_payload_from_row(ing: Ingredient, *, order: Optional[int] = None) -> Dict[str, Any]:
return {
"id": str(ing.id),
"component_id": str(ing.component_id),
"weight_per_head": ing.weight_per_head,
"amount": ing.amount,
"dry_matter": ing.dry_matter,
"order": order if order is not None else ing.order,
}
def group_payload_from_row(grp: UnloadingGroup, *, order: Optional[int] = None) -> Dict[str, Any]:
return {
"id": str(grp.id),
"name": grp.name,
"distribution_type": grp.distribution_type,
"value": grp.value,
"weight": grp.weight,
"order": order if order is not None else grp.order,
}
@@ -0,0 +1,63 @@
"""Матрица сценариев /recipes — id для привязки тестов и guard в ui_contract."""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Literal
Layer = Literal["api", "dual", "e2e"]
@dataclass(frozen=True)
class RecipeScenario:
id: str
layer: Layer
description: str
SCENARIOS: List[RecipeScenario] = [
RecipeScenario("HAPPY_ONE_RECIPE_MOVE", "api", "move up/down одного рейса в периоде"),
RecipeScenario("EDGE_EMPTY_PERIOD_TRANSFER", "api", "transfer в пустой период index 0"),
RecipeScenario("EDGE_MAX_RECIPES_ORDER", "api", "много рейсов — move крайних позиций"),
RecipeScenario("TRANSFER_ENQUEUE_SOURCE_DELETE", "api", "sync delete исходного period_recipes"),
RecipeScenario("TRANSFER_REPEAT_A_B_A", "api", "повторный transfer A→B→A"),
RecipeScenario("DUAL_UNLINK_BOTH_TERMINALS", "dual", "unlink period → A и B"),
RecipeScenario("DUAL_TRANSFER_BOTH_TERMINALS", "dual", "transfer period → A и B"),
RecipeScenario("DUAL_TRANSFER_THEN_REORDER", "dual", "transfer + reorder на B"),
RecipeScenario("DUAL_OFFLINE_CATCHUP_TRANSFER", "dual", "B offline, transfer, догон"),
RecipeScenario("DUAL_LATE_JOIN_AFTER_TRANSFER", "dual", "late join после transfer"),
RecipeScenario("DUAL_REORDER_AND_NEW_GROUP", "dual", "reorder ингредиентов + новая группа → A и B"),
RecipeScenario("DUAL_ADD_GROUP_UPDATES_EXISTING", "dual", "60/40 группы на peer, не 140%"),
RecipeScenario("CALCULATE_FROM_DRY_MATTER", "api", "POST /api/recipes/calculate с calculateFromDryMatter"),
RecipeScenario("SAVE_DRY_MATTER_LOCKED", "api", "сохранение рецепта с dry_matter_locked пересчитывает веса"),
RecipeScenario("DUAL_PUSH_ROUNDTRIP", "dual", "terminal A push → server → B"),
RecipeScenario("DUAL_CONCURRENT_RECIPE", "dual", "устаревший push не перезаписывает master"),
RecipeScenario("DUAL_REPORTS_SYNC", "dual", "loading_report на обоих терминалах"),
RecipeScenario("DUAL_OFFLINE_CATCHUP_GROUPS", "dual", "offline B догоняет reorder + группу"),
RecipeScenario("E2E_DRAG_REORDER", "e2e", "DnD ручкой — порядок + API"),
RecipeScenario("E2E_MOVE_RECIPE_UP", "e2e", "кнопка move-recipe-up меняет order"),
RecipeScenario("E2E_REORDER_THEN_TRANSFER", "e2e", "reorder затем transfer"),
RecipeScenario("E2E_TRANSFER_THEN_REORDER", "e2e", "transfer затем reorder на B"),
RecipeScenario("E2E_TRANSFER_REPEAT", "e2e", "transfer A→B→A"),
RecipeScenario("E2E_TRANSFER_ESCAPE", "e2e", "Escape отменяет transfer"),
RecipeScenario("E2E_HEADS_RECALC", "e2e", "смена голов пересчитывает вес группы heads"),
RecipeScenario("DUAL_E2E_DND_SYNC", "e2e", "DnD на A → sync → B видит order"),
RecipeScenario("DUAL_E2E_TRANSFER_REORDER", "e2e", "transfer на A → sync → B видит рейс"),
RecipeScenario("E2E_EDITOR_UNLOADING_GROUP", "e2e", "add/remove unloading group + save"),
RecipeScenario("E2E_EDITOR_CANCEL", "e2e", "show-recipe-edit-off без save"),
RecipeScenario("JOURNEY_MORNING_ROUTINE", "e2e", "полный еблан: save→reorder→copy→paste→unlink"),
RecipeScenario("JOURNEY_TRANSFER_REPEAT_SLOTS", "e2e", "полный еблан: transfer со слотом A↔B"),
RecipeScenario("JOURNEY_ESCAPE_REORDER_MIX", "e2e", "полный еблан: escape transfer → reorder"),
RecipeScenario("JOURNEY_MILL_CREATE_DELETE", "e2e", "полный еблан: mill create/save/delete global"),
RecipeScenario("JOURNEY_SETTINGS_LOGOUT", "e2e", "полный еблан: settings → logout"),
RecipeScenario("JOURNEY_EMPTY_THEN_FULL", "e2e", "полный еблан: пустой period → create → delete all"),
RecipeScenario("JOURNEY_MOBILE_FLIP", "e2e", "полный еблан: mobile шаги + back + кнопки reorder"),
RecipeScenario("JOURNEY_EDITOR_CANCEL_REOPEN", "e2e", "полный еблан: cancel → другой рейс → save"),
RecipeScenario("JOURNEY_MOVE_BUTTONS_YOYO", "e2e", "полный еблан: up/down спам кнопками"),
RecipeScenario("JOURNEY_DOUBLE_COPY_PASTE", "e2e", "полный еблан: два paste одного copy в period B"),
RecipeScenario("JOURNEY_MILL_DISPENSER_PINGPONG", "e2e", "полный еблан: mill↔dispenser переключения"),
RecipeScenario("JOURNEY_DISTRACTED_UI", "e2e", "полный еблан: help/settings отвлечение → transfer"),
RecipeScenario("JOURNEY_MEGA_MIX", "e2e", "полный еблан: мега-смесь всех операций подряд"),
]
SCENARIO_IDS = [s.id for s in SCENARIOS]
@@ -0,0 +1,210 @@
"""Инвентарь UI страницы /recipes — источник правды для контракт- и E2E-тестов."""
from __future__ import annotations
from pathlib import Path
from typing import Dict, List, Tuple
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
STATIC = PROJECT_ROOT / "static"
# Ключевые DOM id (dashboard + editor + mobile)
DOM_IDS: List[str] = [
"wespZootechNavMount",
"recipesMobileNav",
"recipesMobileNavTitle",
"recipesDashboard",
"dispensersCol",
"periodsCol",
"recipesCol",
"dispensersList",
"periodsList",
"recipesList",
"recipesCardHeaderText",
"createRecipeBtn",
"recipeEdit",
"recipeEditSkeletonPanel",
"recipeForm",
"recipeName",
"headsCount",
"mixingTime",
"tripPercent",
"ingredientsTable",
"ingredientsTableBody",
"unloadingGroupsCard",
"unloadingGroupsTableBody",
"componentSelectionCard",
"targetComponentSelect",
"ingredientMobileSheet",
"unloadingGroupMobileSheet",
"recipeHelpModal",
]
# data-action → файл(ы), где должен быть handler
DATA_ACTION_HANDLERS: Dict[str, List[str]] = {
"mobile-dashboard-back": ["static/js/pages/recipes-page.js"],
"open-recipe-help": ["static/js/pages/recipes-page.js"],
"close-recipe-help": ["static/js/pages/recipes-page.js"],
"logout": ["static/js/pages/recipes-page.js", "static/js/wesp-zootech-nav.js"],
"add-ingredient": ["static/js/modules/recipes/recipe-editor.js"],
"toggle-unloading-link": ["static/js/modules/recipes/recipe-editor.js"],
"add-unloading-group": ["static/js/modules/recipes/recipe-editor.js"],
"show-recipe-edit-off": ["static/js/modules/recipes/recipe-editor.js"],
"print-all-recipes": ["static/js/modules/recipes/recipe-editor.js"],
"print-recipe": ["static/js/modules/recipes/recipe-editor.js"],
"save-and-exit": ["static/js/modules/recipes/recipe-editor.js"],
"move-group-up": ["static/js/modules/recipes/recipe-editor.js"],
"move-group-down": ["static/js/modules/recipes/recipe-editor.js"],
"remove-unloading-group": ["static/js/modules/recipes/recipe-editor.js"],
"move-ingredient-up": ["static/js/modules/recipes/recipe-editor.js"],
"move-ingredient-down": ["static/js/modules/recipes/recipe-editor.js"],
"remove-ingredient": ["static/js/modules/recipes/recipe-editor.js"],
"open-ingredient-sheet": ["static/js/modules/recipes/recipe-editor.js"],
"close-ingredient-sheet": ["static/js/modules/recipes/recipe-editor.js"],
"open-unloading-group-sheet": ["static/js/modules/recipes/recipe-editor.js"],
"close-unloading-group-sheet": ["static/js/modules/recipes/recipe-editor.js"],
"group-type-change": ["static/js/modules/recipes/recipe-editor.js"],
"group-value-change": ["static/js/modules/recipes/recipe-editor.js"],
"ingredient-change": ["static/js/modules/recipes/recipe-editor.js"],
"dry-matter-percent-change": ["static/js/modules/recipes/recipe-editor.js"],
"recalculate-weights": ["static/js/modules/recipes/recipe-editor.js"],
"recalculate-total-weight": ["static/js/modules/recipes/recipe-editor.js"],
"select-dispenser": ["static/js/modules/recipes/dispenser-selector.js"],
"select-recipe": ["static/js/modules/recipes/dispenser-selector.js"],
"select-period": ["static/js/modules/recipes/dispenser-selector.js"],
"copy-recipe": ["static/js/modules/recipes/dispenser-selector.js"],
"delete-recipe": ["static/js/modules/recipes/dispenser-selector.js"],
"unskip-recipe-today": ["static/js/modules/recipes/dispenser-selector.js"],
"unskip-ingredient-parts-today": ["static/js/modules/recipes/dispenser-selector.js"],
"unskip-group-parts-today": ["static/js/modules/recipes/dispenser-selector.js"],
"paste-recipe": ["static/js/modules/recipes/dispenser-selector.js"],
"create-recipe": ["static/js/modules/recipes/dispenser-selector.js"],
"move-recipe-up": ["static/js/modules/recipes/dispenser-selector.js"],
"move-recipe-down": ["static/js/modules/recipes/dispenser-selector.js"],
"recipe-drag-handle": ["static/js/modules/recipes/dispenser-selector.js"],
"open-settings": ["static/js/modules/recipes/sync-panel.js", "static/js/wesp-zootech-nav.js"],
"close-settings": ["static/js/modules/recipes/sync-panel.js"],
"toggle-credentials-form": ["static/js/modules/recipes/sync-panel.js"],
"toggle-sync-form": ["static/js/modules/recipes/sync-panel.js"],
"change-credentials": ["static/js/modules/recipes/sync-panel.js"],
"sync-edit-client": ["static/js/modules/recipes/sync-panel.js"],
"sync-delete-client": ["static/js/modules/recipes/sync-panel.js"],
"sync-save-client": ["static/js/modules/recipes/sync-panel.js"],
"sync-cancel-edit": ["static/js/modules/recipes/sync-panel.js"],
}
# data-action → уровень теста, который должен покрывать action (e2e | api | contract)
ACTION_COVERAGE_TARGET: Dict[str, str] = {
"mobile-dashboard-back": "e2e",
"open-recipe-help": "e2e",
"close-recipe-help": "e2e",
"logout": "e2e",
"add-ingredient": "e2e",
"toggle-unloading-link": "e2e",
"add-unloading-group": "contract",
"show-recipe-edit-off": "contract",
"print-all-recipes": "e2e",
"print-recipe": "e2e",
"save-and-exit": "e2e",
"move-group-up": "api",
"move-group-down": "api",
"remove-unloading-group": "contract",
"move-ingredient-up": "api",
"move-ingredient-down": "api",
"remove-ingredient": "e2e",
"open-ingredient-sheet": "e2e",
"close-ingredient-sheet": "e2e",
"open-unloading-group-sheet": "contract",
"close-unloading-group-sheet": "contract",
"group-type-change": "e2e",
"group-value-change": "contract",
"ingredient-change": "contract",
"dry-matter-percent-change": "e2e",
"recalculate-weights": "contract",
"recalculate-total-weight": "contract",
"select-dispenser": "e2e",
"select-recipe": "e2e",
"select-period": "e2e",
"copy-recipe": "e2e",
"delete-recipe": "e2e",
"unskip-recipe-today": "api",
"unskip-ingredient-parts-today": "api",
"unskip-group-parts-today": "api",
"paste-recipe": "e2e",
"create-recipe": "e2e",
"move-recipe-up": "e2e",
"move-recipe-down": "e2e",
"recipe-drag-handle": "e2e",
"open-settings": "e2e",
"close-settings": "e2e",
"toggle-credentials-form": "e2e",
"toggle-sync-form": "e2e",
"change-credentials": "contract",
"sync-edit-client": "contract",
"sync-delete-client": "contract",
"sync-save-client": "contract",
"sync-cancel-edit": "contract",
}
# fetch URL patterns в JS страницы рецептов
API_ENDPOINTS: List[Tuple[str, List[str]]] = [
("/api/feed_dispensers", ["static/js/pages/recipes-data-controller.js"]),
("/api/feed_dispensers/", ["static/js/pages/recipes-data-controller.js", "static/js/pages/recipes-operations-controller.js"]),
("/api/periods/", ["static/js/pages/recipes-data-controller.js", "static/js/pages/recipes-operations-controller.js"]),
("/api/recipes/calculate", ["static/recipes.html"]),
("/api/recipes/", ["static/recipes.html", "static/js/pages/recipes-operations-controller.js", "static/js/pages/recipes-editor-controller.js"]),
("/api/auth/check", ["static/recipes.html", "static/js/pages/recipes-auth-settings.js"]),
("/api/auth/logout", ["static/js/pages/recipes-auth-settings.js"]),
("/api/auth/change_credentials", ["static/js/pages/recipes-auth-settings.js"]),
("/api/sync/clients", ["static/js/pages/recipes-auth-settings.js"]),
]
# CSS анимации / transition-классы
CSS_ANIMATION_MARKERS: List[Tuple[str, List[str]]] = [
("dashboard-skeleton-shimmer", ["static/css/wesp-recipes-skeleton.css"]),
("dashboard-list-skeleton--exit", ["static/css/wesp-recipes-skeleton.css"]),
("recipe-form--enter", ["static/css/wesp-recipes-editor.css", "static/recipes.html"]),
("dashboard-mobile-step-", ["static/css/wesp-recipes-mobile.css", "static/js/pages/recipes-data-controller.js"]),
]
# Скелетон-templates
SKELETON_TEMPLATE_IDS: List[str] = [
"recipe-card-skeleton-inner",
"recipe-card-skeleton-inner--mill",
"dashboard-list-skeleton-dispensers",
"dashboard-list-skeleton-periods",
]
# Mill-ветки в JS/HTML
MILL_MARKERS: List[Tuple[str, List[str]]] = [
("loadMillRecipes", ["static/js/pages/recipes-data-controller.js"]),
("getCurrentDispenserType() === \"mill\"", [
"static/js/pages/recipes-data-controller.js",
"static/js/pages/recipes-operations-controller.js",
"static/js/pages/recipes-editor-controller.js",
]),
("deleted_ingredient_ids", ["static/recipes.html"]),
("deleted_unloading_group_ids", ["static/recipes.html"]),
("target_component_id", ["static/recipes.html", "static/js/pages/recipes-editor-controller.js"]),
("unlinkFromPeriodOnly", ["static/js/pages/recipes-operations-controller.js"]),
]
# Скрипты, импортируемые из recipes.html (type=module)
RECIPES_PAGE_SCRIPTS: List[str] = [
"recipes-boot.js",
"recipes-page.js",
"recipes-data-controller.js",
"recipes-operations-controller.js",
"recipes-editor-controller.js",
"recipes-auth-settings.js",
"recipe-list-dnd.js",
"recipe-period-transfer.js",
"recipe-mobile-sheet-gestures.js",
]
# Модули, подключаемые через recipes-page.js
RECIPES_PAGE_NESTED_MODULES: List[str] = [
"recipe-editor.js",
"dispenser-selector.js",
"sync-panel.js",
]
+403
View File
@@ -0,0 +1,403 @@
"""Harness: центральный сервер + два терминала (отдельные SQLite) с SyncClient pull/apply/confirm."""
from __future__ import annotations
import os
import socket
import tempfile
import threading
import time
from contextlib import contextmanager
from datetime import datetime
from typing import Any, Dict, Generator, List, Optional, Tuple
from sqlalchemy import select
from werkzeug.serving import make_server
from app import create_app, db
from app.models import (
FeedDispenser,
FeedingPeriod,
Ingredient,
PeriodRecipe,
Recipe,
SyncClient as SyncClientModel,
SyncEngineState,
SyncQueue,
)
from app.services.sync_manager import SNAPSHOT_MODELS, enqueue_sync_queue_task
from config import TestingConfig
from sqlalchemy import func
from sync_client import SyncClient, _attach_local_db_apply, _attach_local_db_push
def _free_port() -> int:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
def _config_for(tmp_dir: str, db_name: str, *, login: str, password: str) -> type:
class _Cfg(TestingConfig):
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(tmp_dir, db_name)}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(tmp_dir, db_name.replace('.db', '_reports.db'))}"}
AUTH_LOGIN = login
AUTH_PASSWORD = password
TESTING = True
return _Cfg
class SyncDualInstanceHarness:
"""Сервер (master DB) + terminal A/B (client DB) в одном тестовом цикле."""
NODE_A = "sync-dual-term-a"
NODE_B = "sync-dual-term-b"
AUTH_LOGIN = "sync-dual-admin"
AUTH_PASSWORD = "sync-dual-secret"
def __init__(self) -> None:
self._tmpdir = tempfile.mkdtemp(prefix="wesp-sync-dual-")
self._server: Any = None
self._server_thread: Optional[threading.Thread] = None
self.server_url = ""
self.server_app = None
self.term_a_app = None
self.term_b_app = None
self.client_a: Optional[SyncClient] = None
self.client_b: Optional[SyncClient] = None
self._ctx_stack: List[Any] = []
def start(self) -> None:
server_cfg = _config_for(
self._tmpdir, "server.db", login=self.AUTH_LOGIN, password=self.AUTH_PASSWORD
)
term_a_cfg = _config_for(
self._tmpdir, "terminal_a.db", login=self.AUTH_LOGIN, password=self.AUTH_PASSWORD
)
term_b_cfg = _config_for(
self._tmpdir, "terminal_b.db", login=self.AUTH_LOGIN, password=self.AUTH_PASSWORD
)
self.server_app = create_app(server_cfg)
self.term_a_app = create_app(term_a_cfg)
self.term_b_app = create_app(term_b_cfg)
for app in (self.server_app, self.term_a_app, self.term_b_app):
ctx = app.app_context()
ctx.push()
self._ctx_stack.append(ctx)
db.create_all()
port = _free_port()
self.server_url = f"http://127.0.0.1:{port}"
self._server = make_server("127.0.0.1", port, self.server_app, threaded=True)
self._server_thread = threading.Thread(target=self._server.serve_forever, daemon=True)
self._server_thread.start()
time.sleep(0.15)
self._register_nodes([self.NODE_A, self.NODE_B])
self._mark_bootstrap_ready([self.NODE_A, self.NODE_B])
self.client_a = self._make_sync_client(
self.term_a_app, self.NODE_A, "Terminal A", attach_push=True
)
self.client_b = self._make_sync_client(
self.term_b_app, self.NODE_B, "Terminal B", attach_push=True
)
def stop(self) -> None:
if self._server is not None:
self._server.shutdown()
self._server = None
for app in (self.server_app, self.term_a_app, self.term_b_app):
if app is not None:
with app.app_context():
db.session.remove()
db.drop_all()
while self._ctx_stack:
self._ctx_stack.pop().pop()
def _register_nodes(self, node_ids: List[str]) -> None:
import requests
for nid in node_ids:
resp = requests.post(
f"{self.server_url}/api/sync/register",
json={"client_id": nid, "client_name": nid},
timeout=10,
)
resp.raise_for_status()
def _mark_bootstrap_ready(self, node_ids: List[str]) -> None:
with self.server_app.app_context():
state = db.session.get(SyncEngineState, 1)
if state is None:
state = SyncEngineState(id=1)
db.session.add(state)
now = datetime.now()
state.universal_bootstrap_completed_at = now
state.universal_bootstrap_cursor = len(SNAPSHOT_MODELS)
for nid in node_ids:
sc = db.session.execute(
select(SyncClientModel).where(SyncClientModel.node_id == nid)
).scalar_one()
sc.personal_snapshot_cursor = len(SNAPSHOT_MODELS)
sc.personal_snapshot_completed_at = now
db.session.commit()
def _make_sync_client(
self,
app: Any,
node_id: str,
name: str,
*,
attach_push: bool = False,
) -> SyncClient:
sc = SyncClient()
sc.server_url = self.server_url
sc.client_id = node_id
sc.client_name = name
sc.role = "client"
sc.config["role"] = "client"
sc._initial_sync_active = False
_attach_local_db_apply(sc, app)
if attach_push:
_attach_local_db_push(sc, app)
return sc
def terminal_test_client(self, which: str):
app = self.term_a_app if which == "a" else self.term_b_app
client = app.test_client()
client.post(
"/api/auth/login",
json={"login": self.AUTH_LOGIN, "password": self.AUTH_PASSWORD},
)
return client
def push_from_terminal(self, which: str) -> Dict[str, Any]:
return self.sync_terminal(which)
def assert_no_duplicate_sync_queue(
self, table_name: str, record_id: str, action: str
) -> None:
with self.server_ctx():
n = int(
db.session.scalar(
select(func.count())
.select_from(SyncQueue)
.where(
SyncQueue.table_name == table_name,
SyncQueue.record_id == record_id,
SyncQueue.action == action,
SyncQueue.status.in_(("pending", "processing")),
)
)
or 0
)
if n > 1:
raise AssertionError(
f"duplicate sync_queue {table_name}/{record_id}/{action}: {n}"
)
def server_test_client(self):
client = self.server_app.test_client()
client.post(
"/api/auth/login",
json={"login": self.AUTH_LOGIN, "password": self.AUTH_PASSWORD},
)
return client
def sync_terminal(self, which: str) -> Dict[str, Any]:
sc = self.client_a if which == "a" else self.client_b
assert sc is not None
return sc.sync_cycle()
def sync_both(self, rounds: int = 3, *, pause_sec: float = 0.05) -> None:
for _ in range(rounds):
self.sync_terminal("a")
self.sync_terminal("b")
if pause_sec:
time.sleep(pause_sec)
def drain_sync(self, *, max_rounds: int = 12, pause_sec: float = 0.08) -> None:
for _ in range(max_rounds):
ra = self.sync_terminal("a")
rb = self.sync_terminal("b")
if (
int(ra.get("pulled") or 0) == 0
and int(rb.get("pulled") or 0) == 0
and not self._pending_universal_tasks()
):
break
time.sleep(pause_sec)
def _pending_universal_tasks(self) -> bool:
with self.server_app.app_context():
row = db.session.scalar(
select(SyncQueue.id)
.where(
SyncQueue.status.in_(("pending", "processing")),
SyncQueue.target_node_id.is_(None),
)
.limit(1)
)
return row is not None
@contextmanager
def server_ctx(self) -> Generator[None, None, None]:
with self.server_app.app_context():
yield
@contextmanager
def terminal_ctx(self, which: str) -> Generator[None, None, None]:
app = self.term_a_app if which == "a" else self.term_b_app
with app.app_context():
yield
def recipe_on_terminal(self, which: str, recipe_id: str) -> Optional[Recipe]:
with self.terminal_ctx(which):
return db.session.get(Recipe, recipe_id)
def active_ingredient_ids(self, which: str, recipe_id: str) -> List[str]:
with self.terminal_ctx(which):
rows = db.session.execute(
select(Ingredient.id).where(
Ingredient.recipe_id == recipe_id,
Ingredient.is_deleted.is_(False),
)
).scalars().all()
return [str(x) for x in rows]
def sync_queue_tasks(self, table_name: str, record_id: str) -> List[SyncQueue]:
with self.server_ctx():
return list(
db.session.execute(
select(SyncQueue)
.where(
SyncQueue.table_name == table_name,
SyncQueue.record_id == record_id,
)
.order_by(SyncQueue.created_at.desc())
)
.scalars()
.all()
)
def period_recipe_link_deleted(
self, which: str, period_id: str, recipe_id: str
) -> bool:
with self.terminal_ctx(which):
row = db.session.get(
PeriodRecipe, {"period_id": period_id, "recipe_id": recipe_id}
)
return row is None or bool(row.is_deleted)
def seed_dispenser_two_periods_two_recipes(
self,
) -> Tuple[str, str, str, str, str]:
"""1 dispenser, period A/B, recipe r1/r2 в A. Sync на оба терминала."""
disp_id = "dual-disp-1"
period_a = "dual-period-a"
period_b = "dual-period-b"
r1, r2 = "dual-r1", "dual-r2"
with self.server_ctx():
db.session.add_all(
[
FeedDispenser(
id=disp_id,
name="Dual D",
farm="F",
operator="O",
type="dispenser",
content_hash="",
),
FeedingPeriod(id=period_a, name="A", dispenser_id=disp_id),
FeedingPeriod(id=period_b, name="B", dispenser_id=disp_id),
Recipe(
id=r1,
name="R1",
heads_per_trip=1,
mixing_time=0,
content_hash="",
),
Recipe(
id=r2,
name="R2",
heads_per_trip=1,
mixing_time=0,
content_hash="",
),
]
)
db.session.flush()
now = datetime.now()
for ord_, rid in enumerate((r1, r2)):
db.session.add(
PeriodRecipe(
period_id=period_a,
recipe_id=rid,
order=ord_,
created_at=now,
created_by="system",
updated_by="system",
)
)
db.session.commit()
for rid in (r1, r2):
enqueue_sync_queue_task("recipe", rid, "create", priority=2, target_node_id=None)
for rid in (r1, r2):
enqueue_sync_queue_task(
"period_recipes",
f"{period_a}:{rid}",
"create",
priority=2,
target_node_id=None,
)
db.session.commit()
self.drain_sync()
return disp_id, period_a, period_b, r1, r2
def period_recipe_order(self, which: str, period_id: str) -> List[str]:
with self.terminal_ctx(which):
rows = db.session.execute(
select(PeriodRecipe.recipe_id)
.where(
PeriodRecipe.period_id == period_id,
PeriodRecipe.is_deleted.is_(False),
)
.order_by(PeriodRecipe.order.asc())
).scalars().all()
return [str(x) for x in rows]
def task_status(self, task_id: str) -> Optional[str]:
with self.server_ctx():
row = db.session.get(SyncQueue, task_id)
return row.status if row else None
def latest_task(
self, table_name: str, record_id: str, action: str
) -> Optional[SyncQueue]:
with self.server_ctx():
return db.session.execute(
select(SyncQueue)
.where(
SyncQueue.table_name == table_name,
SyncQueue.record_id == record_id,
SyncQueue.action == action,
)
.order_by(SyncQueue.created_at.desc())
.limit(1)
).scalar_one_or_none()
def confirm_only(self, which: str, task_ids: List[str]) -> bool:
sc = self.client_a if which == "a" else self.client_b
assert sc is not None
return sc.confirm_tasks(task_ids)
def pull_only(self, which: str, limit: int = 50) -> List[Dict[str, Any]]:
sc = self.client_a if which == "a" else self.client_b
assert sc is not None
return sc.pull_changes(limit)
@@ -0,0 +1,29 @@
"""Общие хелперы для тестов zootech-страниц (не /recipes)."""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from config import TestingConfig
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
STATIC = PROJECT_ROOT / "static"
class ZootechTestConfig(TestingConfig):
_TMP_DIR = tempfile.mkdtemp(prefix="wesp-zootech-test-")
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(_TMP_DIR, 'zootech_test.db')}"
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(_TMP_DIR, 'reports_test.db')}"}
AUTH_LOGIN = "zootech-test-admin"
AUTH_PASSWORD = "zootech-test-secret"
def drain_response(resp) -> None:
try:
resp.get_data()
finally:
close = getattr(resp, "close", None)
if callable(close):
close()