@@ -0,0 +1,414 @@
|
||||
"""Sync всех daily_* таблиц + оператор загрузки vs мастер-рацион."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import create_app, db
|
||||
from app.models import (
|
||||
Component,
|
||||
Ingredient,
|
||||
Recipe,
|
||||
WESP_SUPPRESS_SYNC_ENQUEUE,
|
||||
DailyComponentNormAdjustment,
|
||||
DailyIngredientReplacement,
|
||||
DailyIngredientSkip,
|
||||
DailyTripSkip,
|
||||
DailyUnloadingGroupSkip,
|
||||
SyncQueue,
|
||||
)
|
||||
from app.services.daily_plan.adjustments import adjust_component_norm
|
||||
from app.services.daily_plan.replacements import replace_ingredient
|
||||
from app.services.daily_plan.skips import (
|
||||
skip_ingredient,
|
||||
skip_trip,
|
||||
skip_unloading_group,
|
||||
)
|
||||
from app.services.setup_state import mark_setup_complete
|
||||
from app.services.sync_manager import apply_sync_change
|
||||
from app.services.sync_record_data import get_record_data_for_sync
|
||||
from config import TestingConfig
|
||||
from tests.helpers.daily_plan_operator_helpers import (
|
||||
DAILY_PLAN_SYNC_TABLES,
|
||||
assert_daily_plan_tables_registered,
|
||||
assert_operator_matches_plan_not_master,
|
||||
first_ingredient_amount,
|
||||
master_recipe_json,
|
||||
operator_weight_display,
|
||||
plan_recipe_json,
|
||||
plan_date_today,
|
||||
)
|
||||
from tests.helpers.dispenser_recipe_fixtures import (
|
||||
E2E_DISP_COMP,
|
||||
E2E_DISP_ID,
|
||||
E2E_DISP_RECIPE_1,
|
||||
E2E_DISP_RECIPE_2,
|
||||
E2E_PERIOD_A,
|
||||
seed_dispenser_period_recipes,
|
||||
)
|
||||
from tests.helpers.sync_dual_harness import SyncDualInstanceHarness
|
||||
from tests.helpers.zootech_test_helpers import ZootechTestConfig
|
||||
|
||||
|
||||
def _add_second_ingredient() -> None:
|
||||
db.session.add(
|
||||
Ingredient(
|
||||
id="e2e-disp-ing-extra",
|
||||
name="Ing Extra",
|
||||
weight_per_head=3.0,
|
||||
amount=30.0,
|
||||
dry_matter=55.0,
|
||||
component_id=E2E_DISP_COMP,
|
||||
order=2,
|
||||
recipe_id=E2E_DISP_RECIPE_1,
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _alt_component(component_id: str = "e2e-disp-comp-alt-sync") -> Component:
|
||||
return Component(
|
||||
id=component_id,
|
||||
name="Alt Silo Sync",
|
||||
type="silage",
|
||||
dry_matter=35.0,
|
||||
protein=3.0,
|
||||
energy=6.0,
|
||||
price=0.0,
|
||||
)
|
||||
|
||||
|
||||
class DailyPlanSyncRegistryTests(unittest.TestCase):
|
||||
def test_all_daily_plan_tables_in_sync_pipeline(self) -> None:
|
||||
assert_daily_plan_tables_registered()
|
||||
|
||||
|
||||
class DailyPlanSyncApplyTests(unittest.TestCase):
|
||||
"""apply_sync_change для каждой daily_* таблицы."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.app = create_app(ZootechTestConfig)
|
||||
self.ctx = self.app.app_context()
|
||||
self.ctx.push()
|
||||
db.create_all()
|
||||
mark_setup_complete(self.app)
|
||||
seed_dispenser_period_recipes()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
self.ctx.pop()
|
||||
|
||||
def _apply_roundtrip(self, table_name: str, row, *, recreate: bool = True) -> None:
|
||||
payload = get_record_data_for_sync(table_name, row.id)
|
||||
self.assertIsNotNone(payload, table_name)
|
||||
if recreate:
|
||||
db.session.delete(row)
|
||||
db.session.commit()
|
||||
db.session.info[WESP_SUPPRESS_SYNC_ENQUEUE] = True
|
||||
try:
|
||||
res = apply_sync_change(table_name, row.id, "create", payload or {})
|
||||
finally:
|
||||
db.session.info.pop(WESP_SUPPRESS_SYNC_ENQUEUE, None)
|
||||
self.assertTrue(res["success"], res)
|
||||
|
||||
def test_apply_sync_daily_trip_skip(self) -> None:
|
||||
row = skip_trip(E2E_DISP_RECIPE_1, "2026-06-07")
|
||||
self._apply_roundtrip("daily_trip_skip", row)
|
||||
restored = db.session.get(DailyTripSkip, row.id)
|
||||
self.assertIsNotNone(restored)
|
||||
self.assertEqual(restored.recipe_id, E2E_DISP_RECIPE_1)
|
||||
|
||||
def test_apply_sync_daily_ingredient_skip(self) -> None:
|
||||
row = skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", "2026-06-07")
|
||||
self._apply_roundtrip("daily_ingredient_skip", row)
|
||||
restored = db.session.get(DailyIngredientSkip, row.id)
|
||||
self.assertIsNotNone(restored)
|
||||
|
||||
def test_apply_sync_daily_unloading_group_skip(self) -> None:
|
||||
row = skip_unloading_group(E2E_DISP_RECIPE_1, "e2e-disp-grp-1", "2026-06-07")
|
||||
self._apply_roundtrip("daily_unloading_group_skip", row)
|
||||
restored = db.session.get(DailyUnloadingGroupSkip, row.id)
|
||||
self.assertIsNotNone(restored)
|
||||
|
||||
def test_apply_sync_daily_ingredient_replacement(self) -> None:
|
||||
db.session.add(_alt_component())
|
||||
db.session.commit()
|
||||
row = replace_ingredient(
|
||||
E2E_DISP_RECIPE_1,
|
||||
"e2e-disp-ing-1",
|
||||
"e2e-disp-comp-alt-sync",
|
||||
"2026-06-07",
|
||||
)
|
||||
self._apply_roundtrip("daily_ingredient_replacement", row)
|
||||
restored = db.session.get(DailyIngredientReplacement, row.id)
|
||||
self.assertIsNotNone(restored)
|
||||
|
||||
def test_apply_sync_daily_component_norm_adjustment(self) -> None:
|
||||
row = adjust_component_norm(E2E_DISP_COMP, "2026-06-07", dry_matter=48.0)
|
||||
self._apply_roundtrip("daily_component_norm_adjustment", row)
|
||||
restored = db.session.get(DailyComponentNormAdjustment, row.id)
|
||||
self.assertIsNotNone(restored)
|
||||
self.assertEqual(float(restored.dry_matter or 0), 48.0)
|
||||
|
||||
|
||||
class DailyPlanSyncClientOperatorTests(unittest.TestCase):
|
||||
"""Изменение плана на сервере → sync на клиент → оператор видит план, не мастер."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.app = create_app(ZootechTestConfig)
|
||||
self.ctx = self.app.app_context()
|
||||
self.ctx.push()
|
||||
db.create_all()
|
||||
mark_setup_complete(self.app)
|
||||
seed_dispenser_period_recipes()
|
||||
_add_second_ingredient()
|
||||
db.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
self.ctx.pop()
|
||||
|
||||
def _client_app(self):
|
||||
tmp = tempfile.mkdtemp(prefix="wesp-daily-plan-client-")
|
||||
client_db = os.path.join(tmp, "client.db")
|
||||
|
||||
class ClientCfg(TestingConfig):
|
||||
SQLALCHEMY_DATABASE_URI = f"sqlite:///{client_db}"
|
||||
SQLALCHEMY_BINDS = {
|
||||
"reports": f"sqlite:///{client_db.replace('.db', '_reports.db')}"
|
||||
}
|
||||
|
||||
client_app = create_app(ClientCfg)
|
||||
ctx = client_app.app_context()
|
||||
ctx.push()
|
||||
db.create_all()
|
||||
mark_setup_complete(client_app)
|
||||
seed_dispenser_period_recipes()
|
||||
_add_second_ingredient()
|
||||
db.session.commit()
|
||||
return client_app, ctx
|
||||
|
||||
def _sync_row_to_client(self, table_name: str, row) -> tuple:
|
||||
payload = get_record_data_for_sync(table_name, row.id)
|
||||
self.assertIsNotNone(payload)
|
||||
client_app, client_ctx = self._client_app()
|
||||
db.session.info[WESP_SUPPRESS_SYNC_ENQUEUE] = True
|
||||
try:
|
||||
with client_app.app_context():
|
||||
res = apply_sync_change(table_name, row.id, "create", payload or {})
|
||||
self.assertTrue(res["success"], res)
|
||||
db.session.commit()
|
||||
finally:
|
||||
db.session.info.pop(WESP_SUPPRESS_SYNC_ENQUEUE, None)
|
||||
return client_app, client_ctx
|
||||
|
||||
def test_replacement_sync_operator_sees_plan_not_master(self) -> None:
|
||||
db.session.add(_alt_component("e2e-disp-comp-alt-loading"))
|
||||
recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1)
|
||||
recipe.dry_matter_locked = True
|
||||
db.session.commit()
|
||||
today = plan_date_today()
|
||||
row = replace_ingredient(
|
||||
E2E_DISP_RECIPE_1,
|
||||
"e2e-disp-ing-1",
|
||||
"e2e-disp-comp-alt-loading",
|
||||
today,
|
||||
)
|
||||
client_app, client_ctx = self._sync_row_to_client(
|
||||
"daily_ingredient_replacement", row
|
||||
)
|
||||
try:
|
||||
with client_app.app_context():
|
||||
client_recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1)
|
||||
client_recipe.dry_matter_locked = True
|
||||
db.session.commit()
|
||||
kiosk = client_app.test_client()
|
||||
result = assert_operator_matches_plan_not_master(
|
||||
kiosk, E2E_DISP_RECIPE_1, plan_date=today
|
||||
)
|
||||
self.assertGreater(result["plan_amounts"][0], result["master_amounts"][0])
|
||||
finally:
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
client_ctx.pop()
|
||||
recipe.dry_matter_locked = False
|
||||
db.session.commit()
|
||||
|
||||
def test_ingredient_skip_sync_operator_sees_second_component(self) -> None:
|
||||
today = plan_date_today()
|
||||
row = skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", today)
|
||||
client_app, client_ctx = self._sync_row_to_client("daily_ingredient_skip", row)
|
||||
try:
|
||||
kiosk = client_app.test_client()
|
||||
master = master_recipe_json(kiosk, E2E_DISP_RECIPE_1)
|
||||
plan = plan_recipe_json(kiosk, E2E_DISP_RECIPE_1, plan_date=today)
|
||||
operator = operator_weight_display(kiosk, E2E_DISP_RECIPE_1)
|
||||
|
||||
self.assertEqual(len(master["ingredients"]), 2)
|
||||
self.assertEqual(len(plan["ingredients"]), 1)
|
||||
self.assertEqual(plan["ingredients"][0]["id"], "e2e-disp-ing-extra")
|
||||
plan_amount = float(plan["ingredients"][0]["amount"])
|
||||
self.assertNotEqual(int(round(plan_amount)), int(round(first_ingredient_amount(master))))
|
||||
self.assertEqual(operator["total_component"], int(round(plan_amount)))
|
||||
finally:
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
client_ctx.pop()
|
||||
|
||||
def test_trip_skip_sync_hides_recipe_on_client_period_list(self) -> None:
|
||||
today = plan_date_today()
|
||||
row = skip_trip(E2E_DISP_RECIPE_1, today)
|
||||
queued = db.session.execute(
|
||||
select(SyncQueue).where(
|
||||
SyncQueue.table_name == "daily_trip_skip",
|
||||
SyncQueue.record_id == row.id,
|
||||
SyncQueue.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
self.assertTrue(queued)
|
||||
|
||||
client_app, client_ctx = self._sync_row_to_client("daily_trip_skip", row)
|
||||
try:
|
||||
resp = client_app.test_client().get(f"/api/periods/{E2E_PERIOD_A}/recipes")
|
||||
ids = {r["id"] for r in resp.get_json()}
|
||||
self.assertNotIn(E2E_DISP_RECIPE_1, ids)
|
||||
self.assertIn(E2E_DISP_RECIPE_2, ids)
|
||||
finally:
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
client_ctx.pop()
|
||||
|
||||
def test_unloading_group_skip_sync_applies_on_client(self) -> None:
|
||||
today = plan_date_today()
|
||||
row = skip_unloading_group(E2E_DISP_RECIPE_1, "e2e-disp-grp-1", today)
|
||||
client_app, client_ctx = self._sync_row_to_client("daily_unloading_group_skip", row)
|
||||
try:
|
||||
with client_app.app_context():
|
||||
restored = db.session.get(DailyUnloadingGroupSkip, row.id)
|
||||
self.assertIsNotNone(restored)
|
||||
plan = plan_recipe_json(
|
||||
client_app.test_client(), E2E_DISP_RECIPE_1, plan_date=today, kiosk=False
|
||||
)
|
||||
groups = plan.get("unloadingGroups") or plan.get("unloading_groups") or []
|
||||
self.assertTrue(any(g.get("skippedToday") for g in groups))
|
||||
finally:
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
client_ctx.pop()
|
||||
|
||||
def test_component_norm_adjustment_sync_marks_plan_on_client(self) -> None:
|
||||
today = plan_date_today()
|
||||
row = adjust_component_norm(E2E_DISP_COMP, today, dry_matter=42.0)
|
||||
client_app, client_ctx = self._sync_row_to_client(
|
||||
"daily_component_norm_adjustment", row
|
||||
)
|
||||
try:
|
||||
plan = plan_recipe_json(
|
||||
client_app.test_client(), E2E_DISP_RECIPE_1, plan_date=today, kiosk=False
|
||||
)
|
||||
ing = plan["ingredients"][0]
|
||||
self.assertTrue(ing.get("adjustedToday"))
|
||||
self.assertEqual(float(ing.get("dry_matter") or 0), 42.0)
|
||||
master = master_recipe_json(client_app.test_client(), E2E_DISP_RECIPE_1)
|
||||
self.assertNotIn("adjustedToday", master["ingredients"][0])
|
||||
finally:
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
client_ctx.pop()
|
||||
|
||||
|
||||
class DailyPlanSyncDualHarnessTests(unittest.TestCase):
|
||||
"""Полный цикл: сервер → drain_sync → терминал → оператор."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.harness = SyncDualInstanceHarness()
|
||||
self.harness.start()
|
||||
with self.harness.server_ctx():
|
||||
mark_setup_complete(self.harness.server_app)
|
||||
seed_dispenser_period_recipes()
|
||||
_add_second_ingredient()
|
||||
db.session.add(_alt_component("e2e-disp-comp-dual-sync"))
|
||||
recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1)
|
||||
recipe.dry_matter_locked = True
|
||||
db.session.commit()
|
||||
self.harness.drain_sync()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.harness.stop()
|
||||
|
||||
def test_dual_sync_replacement_operator_matches_plan(self) -> None:
|
||||
today = plan_date_today()
|
||||
with self.harness.server_ctx():
|
||||
row = replace_ingredient(
|
||||
E2E_DISP_RECIPE_1,
|
||||
"e2e-disp-ing-1",
|
||||
"e2e-disp-comp-dual-sync",
|
||||
today,
|
||||
)
|
||||
self.assertTrue(
|
||||
self.harness.sync_queue_tasks("daily_ingredient_replacement", row.id)
|
||||
)
|
||||
self.harness.drain_sync()
|
||||
|
||||
kiosk = self.harness.term_a_app.test_client()
|
||||
with self.harness.terminal_ctx("a"):
|
||||
client_recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1)
|
||||
client_recipe.dry_matter_locked = True
|
||||
db.session.commit()
|
||||
|
||||
result = assert_operator_matches_plan_not_master(kiosk, E2E_DISP_RECIPE_1, plan_date=today)
|
||||
self.assertGreater(result["plan_amounts"][0], result["master_amounts"][0])
|
||||
|
||||
with self.harness.terminal_ctx("b"):
|
||||
repl = db.session.get(DailyIngredientReplacement, row.id)
|
||||
self.assertIsNotNone(repl)
|
||||
|
||||
def test_dual_sync_ingredient_skip_both_terminals(self) -> None:
|
||||
today = plan_date_today()
|
||||
with self.harness.server_ctx():
|
||||
row = skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", today)
|
||||
row_id = row.id
|
||||
self.harness.drain_sync()
|
||||
|
||||
for term in ("a", "b"):
|
||||
kiosk = (
|
||||
self.harness.term_a_app.test_client()
|
||||
if term == "a"
|
||||
else self.harness.term_b_app.test_client()
|
||||
)
|
||||
plan = plan_recipe_json(kiosk, E2E_DISP_RECIPE_1, plan_date=today)
|
||||
self.assertEqual(len(plan["ingredients"]), 1)
|
||||
operator = operator_weight_display(kiosk, E2E_DISP_RECIPE_1)
|
||||
plan_amount = float(plan["ingredients"][0]["amount"])
|
||||
self.assertEqual(operator["total_component"], int(round(plan_amount)))
|
||||
|
||||
with self.harness.terminal_ctx("a"):
|
||||
self.assertIsNotNone(db.session.get(DailyIngredientSkip, row_id))
|
||||
|
||||
def test_dual_sync_trip_skip_period_recipes_on_both_terminals(self) -> None:
|
||||
today = plan_date_today()
|
||||
with self.harness.server_ctx():
|
||||
skip_trip(E2E_DISP_RECIPE_1, today)
|
||||
self.harness.drain_sync()
|
||||
|
||||
for term in ("a", "b"):
|
||||
client = (
|
||||
self.harness.term_a_app.test_client()
|
||||
if term == "a"
|
||||
else self.harness.term_b_app.test_client()
|
||||
)
|
||||
resp = client.get(f"/api/periods/{E2E_PERIOD_A}/recipes")
|
||||
ids = {r["id"] for r in resp.get_json()}
|
||||
self.assertNotIn(E2E_DISP_RECIPE_1, ids, msg=term)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user