Refactor transport calculation into shared domain
This commit is contained in:
@@ -2,6 +2,7 @@ import base64
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import app as rollcalc_app
|
||||
|
||||
@@ -62,6 +63,42 @@ class CalculationApiTests(unittest.TestCase):
|
||||
self.assertEqual(result["state"]["request"]["roll_length_m"], 80.0)
|
||||
self.assertEqual(result["calculation"]["effective_roll_length_m"], 80.0)
|
||||
|
||||
def test_transport_endpoint_delegates_to_shared_domain_service(self):
|
||||
payload = {
|
||||
"length_m": 10,
|
||||
"width_m": 5,
|
||||
"height_m": 5,
|
||||
"max_weight_kg": 10000,
|
||||
"roll_diameter_mm": 1000,
|
||||
"core_diameter_mm": 150,
|
||||
"roll_width_m": 1,
|
||||
"roll_weight_kg": 1000,
|
||||
}
|
||||
with patch.object(
|
||||
rollcalc_app,
|
||||
"analyze_transport",
|
||||
return_value={"status": "success", "analysis": {"final_rolls": 10}},
|
||||
) as analyze:
|
||||
response = self.client.post(
|
||||
"/api/calculations/transport",
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.get_json()["analysis"]["final_rolls"], 10)
|
||||
analyze.assert_called_once_with(payload)
|
||||
|
||||
def test_transport_endpoint_returns_structured_domain_validation(self):
|
||||
response = self.client.post(
|
||||
"/api/calculations/transport",
|
||||
json={"length_m": 0},
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.get_json()["status"], "invalid_parameter")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -8,6 +8,7 @@ from roll_calculation import (
|
||||
CalculationState,
|
||||
_canonical_name,
|
||||
calculate_roll,
|
||||
get_article,
|
||||
modify_calculation,
|
||||
)
|
||||
|
||||
@@ -90,6 +91,35 @@ class RollCalculationTests(unittest.TestCase):
|
||||
def calculate_fixture(self, payload):
|
||||
return calculate_roll(payload, article_repository=self.repository)
|
||||
|
||||
def test_public_get_article_uses_repository_resolution_and_projection(self):
|
||||
result = get_article("00123", article_repository=self.repository)
|
||||
|
||||
self.assertEqual(result["status"], "resolved")
|
||||
self.assertEqual(result["article"]["number"], "00123")
|
||||
self.assertEqual(result["article"]["width_m"], 4.9)
|
||||
self.assertEqual(result["article"]["area_weight_g_m2"], 1000.0)
|
||||
|
||||
def test_public_get_article_preserves_not_found_ambiguous_and_conflict_results(self):
|
||||
self.assertEqual(
|
||||
get_article("does-not-exist", article_repository=self.repository)["status"],
|
||||
"article_not_found",
|
||||
)
|
||||
self.assertEqual(
|
||||
get_article(
|
||||
article_name_hint="Duplicated Product",
|
||||
article_repository=self.repository,
|
||||
)["status"],
|
||||
"article_ambiguous",
|
||||
)
|
||||
self.assertEqual(
|
||||
get_article(
|
||||
"00123",
|
||||
"Other product",
|
||||
article_repository=self.repository,
|
||||
)["status"],
|
||||
"article_conflict",
|
||||
)
|
||||
|
||||
def test_headless_matches_representative_existing_ui_values(self):
|
||||
result = calculate_roll(representative_request())
|
||||
calculation = result["calculation"]
|
||||
|
||||
@@ -39,6 +39,12 @@ class TemplatePdfIntegrationTests(unittest.TestCase):
|
||||
self.assertIn("const warning = result.warnings?.[0]", self.template)
|
||||
self.assertIn("const note = result.notes?.[0]", self.template)
|
||||
|
||||
def test_transport_ui_uses_shared_api_without_a_browser_capacity_calculator(self):
|
||||
self.assertIn("fetch('/api/calculations/transport'", self.template)
|
||||
self.assertIn("this.showResults(payload.analysis)", self.template)
|
||||
self.assertNotIn("calculateLoadUtilization", self.template)
|
||||
self.assertNotIn("class LoadOptimizer {", self.template)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import math
|
||||
import unittest
|
||||
|
||||
from transport_calculation import analyze_transport, transport_presets
|
||||
|
||||
|
||||
def request(**changes):
|
||||
payload = {
|
||||
"length_m": 10.0,
|
||||
"width_m": 5.0,
|
||||
"height_m": 5.0,
|
||||
"max_weight_kg": 10_000.0,
|
||||
"margin_side_m": 0.0,
|
||||
"margin_ceiling_m": 0.0,
|
||||
"roll_diameter_mm": 1000.0,
|
||||
"core_diameter_mm": 150.0,
|
||||
"roll_width_m": 1.0,
|
||||
"roll_weight_kg": 1000.0,
|
||||
"product_length_m": 10.0,
|
||||
}
|
||||
payload.update(changes)
|
||||
return payload
|
||||
|
||||
|
||||
class TransportCalculationTests(unittest.TestCase):
|
||||
def analysis(self, **changes):
|
||||
result = analyze_transport(request(**changes))
|
||||
self.assertEqual(result["status"], "success")
|
||||
return result["analysis"]
|
||||
|
||||
def test_active_presets_match_golden_browser_results(self):
|
||||
expected = {
|
||||
"container_20ft": (8, "Geometry", [21, 19, 8], (2, 2, 2)),
|
||||
"container_40ft": (24, "Geometry", [30, 40, 24], (6, 2, 2)),
|
||||
"container_40ft_hc": (24, "Geometry", [30, 46, 24], (6, 2, 2)),
|
||||
"lkw_sattelzug": (24, "Weight", [24, 54, 24], (6, 2, 2)),
|
||||
"lkw_tandem": (24, "Geometry", [25, 54, 24], (6, 2, 2)),
|
||||
}
|
||||
for preset in transport_presets():
|
||||
with self.subTest(preset=preset.key):
|
||||
result = analyze_transport({
|
||||
"transport_preset": preset.key,
|
||||
"margin_side_m": 0.0,
|
||||
"margin_ceiling_m": 0.2,
|
||||
"roll_diameter_mm": 1000.0,
|
||||
"core_diameter_mm": 150.0,
|
||||
"roll_width_m": 2.0,
|
||||
"roll_weight_kg": 1000.0,
|
||||
"product_length_m": 50.0,
|
||||
})
|
||||
analysis = result["analysis"]
|
||||
rolls, limiting, scenarios, layout = expected[preset.key]
|
||||
self.assertEqual(analysis["final_rolls"], rolls)
|
||||
self.assertEqual(analysis["limiting"]["name"], limiting)
|
||||
self.assertEqual(
|
||||
[scenario["rolls"] for scenario in analysis["scenarios"]],
|
||||
scenarios,
|
||||
)
|
||||
self.assertEqual(
|
||||
(
|
||||
analysis["layout"]["rolls_along_length"],
|
||||
analysis["layout"]["rolls_along_width"],
|
||||
analysis["layout"]["max_stack_height"],
|
||||
),
|
||||
layout,
|
||||
)
|
||||
|
||||
def test_custom_weight_governing_case_matches_browser_semantics(self):
|
||||
analysis = self.analysis()
|
||||
self.assertEqual(analysis["limiting"]["name"], "Weight")
|
||||
self.assertEqual(analysis["final_rolls"], 10)
|
||||
self.assertEqual(analysis["remaining_payload_kg"], 0.0)
|
||||
self.assertEqual(analysis["final_m2"], 100.0)
|
||||
|
||||
def test_geometry_governing_and_impossible_geometry_are_preserved(self):
|
||||
geometry = self.analysis(max_weight_kg=1_000_000.0)
|
||||
self.assertEqual(geometry["limiting"]["name"], "Geometry")
|
||||
self.assertEqual(geometry["final_rolls"], 250)
|
||||
|
||||
impossible = self.analysis(
|
||||
length_m=2.0,
|
||||
width_m=2.0,
|
||||
height_m=2.0,
|
||||
roll_diameter_mm=3000.0,
|
||||
max_weight_kg=100_000.0,
|
||||
)
|
||||
self.assertEqual(impossible["limiting"]["name"], "Geometry")
|
||||
self.assertEqual(impossible["final_rolls"], 0)
|
||||
self.assertEqual(impossible["layout"]["rolls_along_width"], 0)
|
||||
|
||||
def test_volume_governing_case_preserves_unvalidated_browser_sign_behavior(self):
|
||||
# Browser getRollData() rejects zero but not a negative diameter. This
|
||||
# non-physical case characterizes its otherwise reachable volume limit.
|
||||
analysis = self.analysis(
|
||||
roll_diameter_mm=-2400.0,
|
||||
max_weight_kg=100_000.0,
|
||||
)
|
||||
self.assertEqual(analysis["limiting"]["name"], "Volume")
|
||||
self.assertEqual([s["rolls"] for s in analysis["scenarios"]], [100, 55, 90])
|
||||
|
||||
def test_weight_volume_geometry_tie_order_is_weight_volume_geometry(self):
|
||||
analysis = self.analysis(max_weight_kg=250_000.0)
|
||||
self.assertEqual([s["rolls"] for s in analysis["scenarios"]], [250, 325, 250])
|
||||
self.assertEqual(analysis["limiting"]["name"], "Weight")
|
||||
|
||||
def test_floor_and_clearance_behavior_matches_browser_math_floor(self):
|
||||
below_boundary = self.analysis(max_weight_kg=999.9)
|
||||
at_boundary = self.analysis(max_weight_kg=1000.0)
|
||||
self.assertEqual(below_boundary["final_rolls"], 0)
|
||||
self.assertEqual(at_boundary["final_rolls"], 1)
|
||||
|
||||
clearance = self.analysis(margin_side_m=0.5, margin_ceiling_m=1.0)
|
||||
self.assertEqual(clearance["layout"]["effective_width_m"], 4.5)
|
||||
self.assertEqual(clearance["layout"]["effective_height_m"], 4.0)
|
||||
self.assertEqual(clearance["layout"]["rolls_along_width"], 4)
|
||||
self.assertEqual(clearance["layout"]["max_stack_height"], 4)
|
||||
|
||||
def test_displayed_one_decimal_roll_weight_is_consumed_without_recalculation(self):
|
||||
# The direct UI writes d-wo using toFixed(1), then transport reads it.
|
||||
displayed = self.analysis(roll_weight_kg=1000.0)
|
||||
unrounded = self.analysis(roll_weight_kg=1000.04)
|
||||
self.assertEqual(displayed["final_rolls"], 10)
|
||||
self.assertEqual(unrounded["final_rolls"], 9)
|
||||
|
||||
def test_existing_ui_validation_boundary_is_structured_for_the_api(self):
|
||||
for field, value in (("length_m", 0), ("width_m", -1), ("max_weight_kg", 0)):
|
||||
with self.subTest(field=field):
|
||||
result = analyze_transport(request(**{field: value}))
|
||||
self.assertEqual(result["status"], "invalid_parameter")
|
||||
self.assertEqual(result["invalid"][0]["field"], field)
|
||||
|
||||
result = analyze_transport(request(roll_weight_kg=0))
|
||||
self.assertEqual(result["status"], "invalid_parameter")
|
||||
self.assertEqual(result["invalid"][0]["field"], "roll_weight_kg")
|
||||
|
||||
def test_missing_product_length_preserves_browser_zero_square_meter_behavior(self):
|
||||
payload = request()
|
||||
del payload["product_length_m"]
|
||||
result = analyze_transport(payload)
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["analysis"]["final_m2"], 0.0)
|
||||
|
||||
def test_volume_and_utilization_values_are_golden(self):
|
||||
analysis = self.analysis()
|
||||
self.assertAlmostEqual(analysis["volume_per_roll_m3"], 0.7677267047210057)
|
||||
self.assertAlmostEqual(analysis["utilization_weight"], 100.0)
|
||||
self.assertAlmostEqual(analysis["utilization_volume"], 3.0709068188840227)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user