214 lines
8.4 KiB
Python
214 lines
8.4 KiB
Python
"""Согласованность POST /api/recipes/calculate с app.services.recipe_calculator."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import tempfile
|
||
import unittest
|
||
|
||
from app import create_app, db
|
||
from app.models import Component
|
||
from app.services.recipe_calculator import calculate_recipe
|
||
from config import TestingConfig
|
||
|
||
|
||
class RecipeCalculatePipelineConfig(TestingConfig):
|
||
_TMP_DIR = tempfile.mkdtemp(prefix="wesp-calc-pipeline-")
|
||
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(_TMP_DIR, 'calc_pipeline.db')}"
|
||
SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(_TMP_DIR, 'reports_pipeline.db')}"}
|
||
AUTH_LOGIN = "calc-pipeline-admin"
|
||
AUTH_PASSWORD = "calc-pipeline-secret"
|
||
|
||
|
||
def _format_like_api(result: dict) -> dict:
|
||
"""Те же правила округления, что в calculate_recipe_endpoint."""
|
||
out = {
|
||
"ingredients": [dict(i) for i in result.get("ingredients", [])],
|
||
"totals": dict(result.get("totals", {})),
|
||
"unloadingGroups": [dict(g) for g in result.get("unloadingGroups", [])],
|
||
"unloadingTotals": dict(result.get("unloadingTotals", {})),
|
||
}
|
||
|
||
def to_float2(x):
|
||
try:
|
||
return round(float(x), 2)
|
||
except (TypeError, ValueError):
|
||
return 0.0
|
||
|
||
def truncate2(x):
|
||
try:
|
||
v = float(x)
|
||
return float(int(v * 100)) / 100.0
|
||
except (TypeError, ValueError):
|
||
return 0.0
|
||
|
||
for ing in out["ingredients"]:
|
||
for key in ("weightPerHead", "tripWeight", "totalWeight", "dryMatterPerHead"):
|
||
if key in ing and ing[key] is not None:
|
||
raw_val = ing[key]
|
||
v = truncate2(raw_val) if key == "weightPerHead" else to_float2(raw_val)
|
||
ing[key] = f"{v:.2f}" if key == "weightPerHead" else v
|
||
|
||
for key in (
|
||
"totalWeight",
|
||
"totalTripWeight",
|
||
"totalDryMatterPerHead",
|
||
"totalWeightPerHead",
|
||
):
|
||
if key in out["totals"] and out["totals"][key] is not None:
|
||
out["totals"][key] = to_float2(out["totals"][key])
|
||
|
||
return out
|
||
|
||
|
||
class RecipeCalculatePipelineTests(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
self.app = create_app(RecipeCalculatePipelineConfig)
|
||
self.client = self.app.test_client()
|
||
self.ctx = self.app.app_context()
|
||
self.ctx.push()
|
||
db.create_all()
|
||
self.client.post(
|
||
"/api/auth/login",
|
||
json={"login": "calc-pipeline-admin", "password": "calc-pipeline-secret"},
|
||
)
|
||
self.comp_grain = "pipe-comp-grain"
|
||
self.comp_protein = "pipe-comp-protein"
|
||
db.session.add_all(
|
||
[
|
||
Component(
|
||
id=self.comp_grain,
|
||
name="Зерно",
|
||
type="grain",
|
||
dry_matter=80.0,
|
||
protein=0.0,
|
||
energy=0.0,
|
||
price=0.0,
|
||
),
|
||
Component(
|
||
id=self.comp_protein,
|
||
name="Белок",
|
||
type="protein",
|
||
dry_matter=90.0,
|
||
protein=0.0,
|
||
energy=0.0,
|
||
price=0.0,
|
||
),
|
||
]
|
||
)
|
||
db.session.commit()
|
||
|
||
def tearDown(self) -> None:
|
||
db.session.remove()
|
||
db.drop_all()
|
||
self.ctx.pop()
|
||
|
||
def _post_calculate(self, payload: dict) -> dict:
|
||
resp = self.client.post("/api/recipes/calculate", json=payload)
|
||
self.assertEqual(resp.status_code, 200, resp.get_data(as_text=True))
|
||
return resp.get_json()
|
||
|
||
def _expected_from_service(self, payload: dict) -> dict:
|
||
heads_count = int(
|
||
payload.get("headsCount")
|
||
or payload.get("heads_count")
|
||
or payload.get("headsPerTrip")
|
||
or 0
|
||
)
|
||
trip_percent = float(payload.get("tripPercent") or payload.get("trip_percent") or 100)
|
||
ingredients = payload.get("ingredients", []) or []
|
||
unloading_groups = payload.get("unloadingGroups") or payload.get("unloading_groups") or []
|
||
calculate_from_dry_matter = bool(
|
||
payload.get("calculateFromDryMatter")
|
||
if payload.get("calculateFromDryMatter") is not None
|
||
else payload.get("calculate_from_dry_matter", False)
|
||
)
|
||
|
||
normalized_ingredients = []
|
||
for ing in ingredients:
|
||
ing = dict(ing)
|
||
if "component_id" not in ing and "componentId" in ing:
|
||
ing["component_id"] = ing.get("componentId")
|
||
if "dryMatterPerHead" not in ing and "dry_matter_per_head" in ing:
|
||
ing["dryMatterPerHead"] = ing.get("dry_matter_per_head")
|
||
if "dryMatter" not in ing and "dry_matter" in ing:
|
||
ing["dryMatter"] = ing.get("dry_matter")
|
||
if "weightPerHead" not in ing and "weight_per_head" in ing:
|
||
ing["weightPerHead"] = ing.get("weight_per_head")
|
||
normalized_ingredients.append(ing)
|
||
|
||
normalized_groups = []
|
||
for g in unloading_groups:
|
||
g = dict(g)
|
||
if "distributionType" not in g and "distribution_type" in g:
|
||
g["distributionType"] = g.get("distribution_type")
|
||
normalized_groups.append(g)
|
||
|
||
component_ids = [i.get("component_id") for i in normalized_ingredients if i.get("component_id")]
|
||
component_dry_matter_map = {}
|
||
if component_ids:
|
||
comps = db.session.query(Component).filter(Component.id.in_(component_ids)).all()
|
||
component_dry_matter_map = {c.id: c.dry_matter for c in comps}
|
||
|
||
raw = calculate_recipe(
|
||
ingredients=normalized_ingredients,
|
||
heads_count=heads_count,
|
||
trip_percent=trip_percent,
|
||
unloading_groups=normalized_groups,
|
||
component_dry_matter_map=component_dry_matter_map or None,
|
||
calculate_from_dry_matter=calculate_from_dry_matter,
|
||
)
|
||
return _format_like_api(raw)
|
||
|
||
def test_api_matches_service_weight_mode_with_groups(self) -> None:
|
||
payload = {
|
||
"headsCount": 137,
|
||
"tripPercent": 75.0,
|
||
"ingredients": [
|
||
{"weightPerHead": 2.5, "dryMatter": 50.0, "componentId": self.comp_grain},
|
||
{"weightPerHead": 1.0, "dryMatter": 0, "componentId": self.comp_protein},
|
||
],
|
||
"unloadingGroups": [
|
||
{"distributionType": "heads", "value": 67},
|
||
{"distributionType": "heads", "value": 68},
|
||
],
|
||
}
|
||
api = self._post_calculate(payload)
|
||
expected = self._expected_from_service(payload)
|
||
self.assertEqual(api["totals"], expected["totals"])
|
||
self.assertEqual(api["unloadingGroups"], expected["unloadingGroups"])
|
||
self.assertEqual(api["unloadingTotals"], expected["unloadingTotals"])
|
||
self.assertEqual(len(api["ingredients"]), 2)
|
||
self.assertEqual(api["ingredients"][0]["weightPerHead"], expected["ingredients"][0]["weightPerHead"])
|
||
self.assertEqual(api["ingredients"][1]["dryMatterPerHead"], expected["ingredients"][1]["dryMatterPerHead"])
|
||
|
||
def test_api_matches_service_dry_matter_mode(self) -> None:
|
||
payload = {
|
||
"heads_count": 50,
|
||
"trip_percent": 100,
|
||
"calculate_from_dry_matter": True,
|
||
"ingredients": [
|
||
{"dry_matter_per_head": 0.8, "dry_matter": 0, "component_id": self.comp_grain},
|
||
{"dry_matter_per_head": 0.45, "dryMatter": 90.0, "component_id": self.comp_protein},
|
||
],
|
||
"unloading_groups": [{"distribution_type": "percent", "value": 100}],
|
||
}
|
||
api = self._post_calculate(payload)
|
||
expected = self._expected_from_service(payload)
|
||
self.assertEqual(api["totals"], expected["totals"])
|
||
self.assertEqual(api["ingredients"][0]["weightPerHead"], expected["ingredients"][0]["weightPerHead"])
|
||
self.assertEqual(api["ingredients"][1]["weightPerHead"], expected["ingredients"][1]["weightPerHead"])
|
||
self.assertEqual(api["unloadingGroups"][0]["calculatedWeight"], expected["unloadingGroups"][0]["calculatedWeight"])
|
||
|
||
def test_api_rejects_invalid_numeric_params(self) -> None:
|
||
resp = self.client.post(
|
||
"/api/recipes/calculate",
|
||
json={"headsCount": "abc", "ingredients": []},
|
||
)
|
||
self.assertEqual(resp.status_code, 400)
|
||
self.assertIn("error", resp.get_json())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|