Files
site/WESP_REL/tests/test_daily_plan_sync.py
2026-07-17 12:57:18 +03:00

415 lines
16 KiB
Python

"""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()