Refactor transport calculation into shared domain

This commit is contained in:
2026-09-24 13:11:18 +02:00
parent fa3000b325
commit defbf20202
10 changed files with 582 additions and 132 deletions
+37
View File
@@ -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()
+30
View File
@@ -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"]
+6
View File
@@ -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()
+152
View File
@@ -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()