524 lines
19 KiB
Python
524 lines
19 KiB
Python
import json
|
||
import math
|
||
import unittest
|
||
|
||
from roll_calculation import (
|
||
ArticleRepository,
|
||
CalculationRequest,
|
||
CalculationState,
|
||
_canonical_name,
|
||
calculate_roll,
|
||
modify_calculation,
|
||
)
|
||
|
||
|
||
def representative_request(**changes):
|
||
payload = {
|
||
"article_number": "146900",
|
||
"roll_length_m": 50.0,
|
||
"width_m": 5.8,
|
||
"core_diameter_mm": 150.0,
|
||
"include_roll_weight": True,
|
||
}
|
||
payload.update(changes)
|
||
return payload
|
||
|
||
|
||
class RollCalculationTests(unittest.TestCase):
|
||
def setUp(self):
|
||
self.repository = ArticleRepository(
|
||
[
|
||
{
|
||
"nr": "00123",
|
||
"name": "Alpha Product, 4,90 x 20 m",
|
||
"thickness": 2.0,
|
||
"thickness_stddev": 0.1,
|
||
"area_weight": 1000.0,
|
||
"core_type": 0.0,
|
||
},
|
||
{
|
||
"nr": "00999",
|
||
"name": "Product Without Width",
|
||
"thickness": 3.0,
|
||
"thickness_stddev": 0.0,
|
||
"area_weight": 900.0,
|
||
"core_type": 0.0,
|
||
},
|
||
{
|
||
"nr": "01000",
|
||
"name": "Duplicated Product",
|
||
"thickness": 3.0,
|
||
"thickness_stddev": 0.0,
|
||
"area_weight": 900.0,
|
||
"core_type": 0.0,
|
||
},
|
||
{
|
||
"nr": "01001",
|
||
"name": "Duplicated Product",
|
||
"thickness": 3.0,
|
||
"thickness_stddev": 0.0,
|
||
"area_weight": 900.0,
|
||
"core_type": 0.0,
|
||
},
|
||
{
|
||
"nr": "02000",
|
||
"name": "Shared Product, 4,90 x 20 m",
|
||
"thickness": 2.0,
|
||
"thickness_stddev": 0.1,
|
||
"area_weight": 1000.0,
|
||
"core_type": 0.0,
|
||
},
|
||
{
|
||
"nr": "02001",
|
||
"name": "Shared Product, 5,00 x 40 m",
|
||
"thickness": 2.0,
|
||
"thickness_stddev": 0.1,
|
||
"area_weight": 1000.0,
|
||
"core_type": 0.0,
|
||
},
|
||
{
|
||
"nr": "03000",
|
||
"name": "Bfix Alias Product",
|
||
"thickness": 2.0,
|
||
"thickness_stddev": 0.1,
|
||
"area_weight": 1000.0,
|
||
"core_type": 0.0,
|
||
},
|
||
]
|
||
)
|
||
|
||
def calculate_fixture(self, payload):
|
||
return calculate_roll(payload, article_repository=self.repository)
|
||
|
||
def test_headless_matches_representative_existing_ui_values(self):
|
||
result = calculate_roll(representative_request())
|
||
calculation = result["calculation"]
|
||
|
||
self.assertEqual(result["status"], "success")
|
||
self.assertAlmostEqual(calculation["minimum_diameter_mm"], 813.0877908536811)
|
||
self.assertAlmostEqual(calculation["average_diameter_mm"], 845.8528172323103)
|
||
self.assertAlmostEqual(calculation["maximum_diameter_mm"], 877.3951340213555)
|
||
self.assertEqual(calculation["effective_roll_length_m"], 50.0)
|
||
self.assertAlmostEqual(calculation["roll_weight_kg"], 512.4909)
|
||
|
||
def test_optional_weight_is_omitted_without_requesting_it(self):
|
||
result = calculate_roll(
|
||
representative_request(width_m=None, include_roll_weight=False)
|
||
)
|
||
self.assertEqual(result["status"], "success")
|
||
self.assertIsNone(result["calculation"]["roll_weight_kg"])
|
||
|
||
def test_diameter_only_calculation_does_not_require_width(self):
|
||
result = self.calculate_fixture(
|
||
{
|
||
"article_number": "00999",
|
||
"roll_length_m": 20,
|
||
"core_diameter_mm": 150,
|
||
"include_roll_weight": False,
|
||
}
|
||
)
|
||
|
||
self.assertEqual(result["status"], "success")
|
||
self.assertNotIn("width_m", result["effective_inputs"])
|
||
self.assertIsNone(result["calculation"]["roll_weight_kg"])
|
||
|
||
def test_missing_core_diameter_blocks_diameter_calculation(self):
|
||
result = self.calculate_fixture(
|
||
{
|
||
"article_number": "00999",
|
||
"roll_length_m": 20,
|
||
"include_roll_weight": False,
|
||
}
|
||
)
|
||
|
||
self.assertEqual(result["status"], "needs_clarification")
|
||
self.assertIn("core_diameter_mm", result["missing"])
|
||
self.assertNotIn("width_m", result["missing"])
|
||
|
||
def test_warning_and_note_rules_are_preserved(self):
|
||
heavy = calculate_roll(
|
||
{
|
||
"roll_length_m": 100,
|
||
"width_m": 4,
|
||
"thickness_mm": 5,
|
||
"area_weight_g_m2": 5000,
|
||
"core_diameter_mm": 150,
|
||
"category": "bentofix",
|
||
"include_roll_weight": True,
|
||
}
|
||
)
|
||
normal = calculate_roll(representative_request(category="bentofix"))
|
||
|
||
self.assertEqual(heavy["warnings"][0]["severity"], "warning")
|
||
self.assertIn("min 170 mm required", heavy["warnings"][0]["message"])
|
||
self.assertIn("within standard limits", normal["notes"][0])
|
||
|
||
def test_exact_article_number_preserves_leading_zeroes(self):
|
||
result = self.calculate_fixture(
|
||
{"article_number": "00123", "roll_length_m": 20, "core_diameter_mm": 150}
|
||
)
|
||
self.assertEqual(result["status"], "success")
|
||
self.assertEqual(result["resolved_article"]["number"], "00123")
|
||
numeric = self.calculate_fixture(
|
||
{"article_number": 123, "roll_length_m": 20, "core_diameter_mm": 150}
|
||
)
|
||
self.assertEqual(numeric["status"], "invalid_parameter")
|
||
|
||
def test_exact_normalized_article_name_resolves_without_number(self):
|
||
result = self.calculate_fixture({
|
||
"article_name_hint": "Alpha Product, 4,90 x 20 m",
|
||
"roll_length_m": 20,
|
||
"core_diameter_mm": 150,
|
||
})
|
||
|
||
self.assertEqual(result["status"], "success")
|
||
self.assertEqual(result["resolved_article"]["number"], "00123")
|
||
|
||
def test_article_name_matching_is_case_insensitive(self):
|
||
result = self.calculate_fixture({
|
||
"article_name_hint": "pRoDuCt WiThOuT wIdTh",
|
||
"roll_length_m": 20,
|
||
"core_diameter_mm": 150,
|
||
})
|
||
|
||
self.assertEqual(result["status"], "success")
|
||
self.assertEqual(result["resolved_article"]["number"], "00999")
|
||
|
||
def test_article_name_normalizes_safe_punctuation_and_whitespace(self):
|
||
result = self.calculate_fixture({
|
||
"article_name_hint": " Alpha---Product ; 4.90 × 20 M ",
|
||
"roll_length_m": 20,
|
||
"core_diameter_mm": 150,
|
||
})
|
||
|
||
self.assertEqual(result["status"], "success")
|
||
self.assertEqual(result["resolved_article"]["number"], "00123")
|
||
|
||
def test_model_designator_spacing_matches_the_same_article_family(self):
|
||
repository = ArticleRepository([
|
||
{"nr": "04000", "name": "Stex R 501, 5,20 x 100 m"},
|
||
{"nr": "04001", "name": "Stex R 501, 6,00 x 100 m"},
|
||
])
|
||
|
||
resolution = repository.resolve(None, "Stex R501")
|
||
|
||
self.assertEqual(resolution["status"], "article_ambiguous")
|
||
self.assertEqual(
|
||
[candidate["article_number"] for candidate in resolution["candidates"]],
|
||
["04000", "04001"],
|
||
)
|
||
|
||
def test_base_name_ignores_dimensions_and_parenthetical_descriptor(self):
|
||
repository = ArticleRepository([
|
||
{
|
||
"nr": "04000",
|
||
"name": "Stex R 501 (Betonvlies), 5,20 x 100 m",
|
||
},
|
||
])
|
||
|
||
resolution = repository.resolve(None, "Stex R-501")
|
||
|
||
self.assertEqual(resolution["status"], "resolved")
|
||
self.assertEqual(resolution["article"]["number"], "04000")
|
||
|
||
def test_secutex_and_stex_r501_have_the_same_ambiguous_candidates(self):
|
||
repository = ArticleRepository([
|
||
{"nr": "04000", "name": "Stex R 501, 5,20 x 100 m"},
|
||
{"nr": "04001", "name": "Stex R 501, 6,00 x 100 m"},
|
||
])
|
||
|
||
spaced = repository.resolve(None, "Secutex R 501")
|
||
compact = repository.resolve(None, "sEcUtEx R501")
|
||
stored = repository.resolve(None, "Stex R501")
|
||
|
||
self.assertEqual(spaced["status"], "article_ambiguous")
|
||
self.assertEqual(compact["status"], "article_ambiguous")
|
||
self.assertEqual(
|
||
spaced["candidates"],
|
||
stored["candidates"],
|
||
)
|
||
self.assertEqual(compact["candidates"], stored["candidates"])
|
||
|
||
def test_bentofix_and_bfix_resolve_equivalently(self):
|
||
repository = ArticleRepository([
|
||
{"nr": "04000", "name": "Bfix NSP 4900, 5,00 x 40 m"},
|
||
])
|
||
|
||
full = repository.resolve(None, "Bentofix NSP 4900")
|
||
abbreviated = repository.resolve(None, "Bfix NSP 4900")
|
||
|
||
self.assertEqual(full, abbreviated)
|
||
self.assertEqual(full["status"], "resolved")
|
||
|
||
def test_secugrid_and_sgrid_have_the_same_family_matching_behavior(self):
|
||
repository = ArticleRepository([
|
||
{"nr": "04000", "name": "Sgrid 40/40 Q6, 4,75 x 100 m"},
|
||
{"nr": "04001", "name": "Sgrid 40/40 Q6, 4,75 x 50 m"},
|
||
])
|
||
|
||
full = repository.resolve(None, "Secugrid 40/40 Q6")
|
||
abbreviated = repository.resolve(None, "Sgrid 40/40 Q6")
|
||
|
||
self.assertEqual(full["status"], "article_ambiguous")
|
||
self.assertEqual(full["candidates"], abbreviated["candidates"])
|
||
|
||
def test_product_family_aliases_are_token_bounded(self):
|
||
self.assertEqual(_canonical_name("Secutex R501"), "stex r501")
|
||
self.assertEqual(_canonical_name("Disecutex R501"), "disecutex r501")
|
||
self.assertEqual(_canonical_name("Bentofixation NSP4900"), "bentofixation nsp 4900")
|
||
|
||
def test_weak_fuzzy_match_is_not_silently_selected(self):
|
||
resolution = self.repository.resolve(None, "Alphx Product")
|
||
|
||
self.assertEqual(resolution["status"], "article_ambiguous")
|
||
self.assertEqual(resolution["reason"], "uncertain_article_match")
|
||
self.assertEqual(
|
||
resolution["candidates"][0]["article_number"],
|
||
"00123",
|
||
)
|
||
|
||
def test_secutex_r501_alias_returns_only_repository_candidates(self):
|
||
repository = ArticleRepository.load()
|
||
resolution = repository.resolve(None, "Secutex R501")
|
||
|
||
self.assertEqual(resolution["status"], "article_ambiguous")
|
||
candidate_numbers = {
|
||
candidate["article_number"] for candidate in resolution["candidates"]
|
||
}
|
||
self.assertIn("214507", candidate_numbers)
|
||
self.assertIn("214508", candidate_numbers)
|
||
self.assertTrue(candidate_numbers <= set(repository.by_number))
|
||
|
||
def test_shared_base_name_returns_repository_candidates(self):
|
||
result = self.calculate_fixture({
|
||
"article_name_hint": "Shared Product",
|
||
"roll_length_m": 20,
|
||
"core_diameter_mm": 150,
|
||
})
|
||
|
||
self.assertEqual(result["status"], "needs_clarification")
|
||
self.assertEqual(result["field"], "article")
|
||
self.assertEqual(result["reason"], "ambiguous_article")
|
||
self.assertEqual(result["candidates"], [
|
||
{
|
||
"article_number": "02000",
|
||
"name": "Shared Product, 4,90 x 20 m",
|
||
},
|
||
{
|
||
"article_number": "02001",
|
||
"name": "Shared Product, 5,00 x 40 m",
|
||
},
|
||
])
|
||
|
||
def test_unknown_conflicting_and_ambiguous_articles_are_structured(self):
|
||
unknown = self.calculate_fixture(
|
||
{"article_number": "404", "roll_length_m": 20, "core_diameter_mm": 150}
|
||
)
|
||
conflict = self.calculate_fixture(
|
||
{
|
||
"article_number": "00123",
|
||
"article_name_hint": "Different Product",
|
||
"roll_length_m": 20,
|
||
"core_diameter_mm": 150,
|
||
}
|
||
)
|
||
ambiguous = self.calculate_fixture(
|
||
{
|
||
"article_name_hint": "Duplicated Product",
|
||
"roll_length_m": 20,
|
||
"core_diameter_mm": 150,
|
||
}
|
||
)
|
||
self.assertEqual(unknown["status"], "article_not_found")
|
||
self.assertEqual(conflict["status"], "article_conflict")
|
||
self.assertEqual(ambiguous["status"], "needs_clarification")
|
||
self.assertEqual(ambiguous["ambiguous"], ["article_name_hint"])
|
||
|
||
def test_duplicate_exact_article_number_is_never_guessed(self):
|
||
repository = ArticleRepository(
|
||
[
|
||
{
|
||
"nr": "00123",
|
||
"name": "First Record",
|
||
"thickness": 2.0,
|
||
},
|
||
{
|
||
"nr": "00123",
|
||
"name": "Conflicting Record",
|
||
"thickness": 3.0,
|
||
},
|
||
]
|
||
)
|
||
result = calculate_roll(
|
||
{
|
||
"article_number": "00123",
|
||
"roll_length_m": 20,
|
||
"core_diameter_mm": 150,
|
||
},
|
||
article_repository=repository,
|
||
)
|
||
|
||
self.assertEqual(result["status"], "needs_clarification")
|
||
self.assertEqual(result["ambiguous"], ["article_number"])
|
||
self.assertEqual(len(result["article_resolution"]["matches"]), 2)
|
||
|
||
def test_name_hint_validation_accepts_deterministic_bfix_alias(self):
|
||
result = calculate_roll(
|
||
{
|
||
"article_number": "180205",
|
||
"article_name_hint": "Bentofix NSP 4900",
|
||
"roll_length_m": 65,
|
||
"core_diameter_mm": 150,
|
||
}
|
||
)
|
||
self.assertEqual(result["status"], "success")
|
||
|
||
def test_name_only_matching_uses_deterministic_bfix_alias(self):
|
||
result = self.calculate_fixture({
|
||
"article_name_hint": "Bentofix Alias Product",
|
||
"roll_length_m": 65,
|
||
"core_diameter_mm": 150,
|
||
})
|
||
|
||
self.assertEqual(result["status"], "success")
|
||
self.assertEqual(result["resolved_article"]["number"], "03000")
|
||
|
||
def test_missing_width_requests_clarification_when_not_in_master_data(self):
|
||
result = self.calculate_fixture(
|
||
{
|
||
"article_number": "00999",
|
||
"roll_length_m": 20,
|
||
"core_diameter_mm": 150,
|
||
"include_roll_weight": True,
|
||
}
|
||
)
|
||
self.assertEqual(result["status"], "needs_clarification")
|
||
self.assertIn("width_m", result["missing"])
|
||
|
||
def test_unambiguous_master_width_is_used_with_provenance(self):
|
||
result = self.calculate_fixture(
|
||
{
|
||
"article_number": "00123",
|
||
"roll_length_m": 20,
|
||
"core_diameter_mm": 150,
|
||
"include_roll_weight": True,
|
||
}
|
||
)
|
||
self.assertEqual(result["status"], "success")
|
||
self.assertEqual(result["effective_inputs"]["width_m"], {
|
||
"value": 4.9,
|
||
"source": "article_master_data",
|
||
})
|
||
self.assertAlmostEqual(result["calculation"]["roll_weight_kg"], 98.0)
|
||
|
||
def test_invalid_values_and_caller_results_are_rejected(self):
|
||
invalid = calculate_roll(
|
||
{"roll_length_m": -1, "thickness_mm": 2, "core_diameter_mm": 150}
|
||
)
|
||
supplied = calculate_roll(
|
||
{
|
||
"roll_length_m": 10,
|
||
"thickness_mm": 2,
|
||
"core_diameter_mm": 150,
|
||
"diameters": {"average": 1},
|
||
}
|
||
)
|
||
self.assertEqual(invalid["status"], "invalid_parameter")
|
||
self.assertEqual(supplied["status"], "invalid_parameter")
|
||
self.assertIn("calculated result fields", supplied["invalid"][0]["message"])
|
||
self.assertNotIn("request", supplied)
|
||
|
||
def test_extreme_values_cannot_produce_non_finite_results(self):
|
||
result = calculate_roll(
|
||
{
|
||
"roll_length_m": 1e308,
|
||
"thickness_mm": 1e308,
|
||
"core_diameter_mm": 150,
|
||
}
|
||
)
|
||
|
||
self.assertEqual(result["status"], "invalid_parameter")
|
||
self.assertIn("supported calculation range", result["invalid"][0]["message"])
|
||
|
||
def test_request_result_and_state_are_json_serializable(self):
|
||
request = CalculationRequest.from_dict(representative_request())
|
||
state = CalculationState(request)
|
||
result = calculate_roll(request)
|
||
json.dumps(state.to_dict())
|
||
json.dumps(result)
|
||
|
||
|
||
class CalculationMutationTests(unittest.TestCase):
|
||
def setUp(self):
|
||
self.state = CalculationState(
|
||
CalculationRequest.from_dict(representative_request())
|
||
)
|
||
|
||
def test_changing_length_recalculates_from_structured_state(self):
|
||
result = modify_calculation(self.state, {"roll_length_m": 80})
|
||
self.assertEqual(result["status"], "success")
|
||
self.assertEqual(result["state"]["request"]["roll_length_m"], 80.0)
|
||
self.assertGreater(
|
||
result["calculation"]["average_diameter_mm"], 845.8528172323103
|
||
)
|
||
|
||
def test_changing_width_recalculates_weight(self):
|
||
result = modify_calculation(self.state, {"width_m": 4.9})
|
||
self.assertEqual(result["status"], "success")
|
||
self.assertAlmostEqual(result["calculation"]["roll_weight_kg"], 432.96645)
|
||
|
||
def test_multiple_changes_are_applied_as_one_validated_state(self):
|
||
result = modify_calculation(
|
||
self.state,
|
||
{"roll_length_m": 80, "width_m": 5.0, "include_roll_weight": True},
|
||
)
|
||
|
||
self.assertEqual(result["status"], "success")
|
||
request = result["state"]["request"]
|
||
self.assertEqual(request["roll_length_m"], 80.0)
|
||
self.assertEqual(request["width_m"], 5.0)
|
||
self.assertTrue(request["include_roll_weight"])
|
||
self.assertIsNotNone(result["calculation"]["roll_weight_kg"])
|
||
|
||
def test_invalid_multi_change_does_not_mutate_the_existing_state(self):
|
||
original = self.state.to_dict()
|
||
result = modify_calculation(
|
||
self.state,
|
||
{"roll_length_m": 80, "unknown_input": 5.0},
|
||
)
|
||
|
||
self.assertEqual(result["status"], "invalid_parameter")
|
||
self.assertEqual(self.state.to_dict(), original)
|
||
|
||
def test_valid_core_change_recalculates(self):
|
||
result = modify_calculation(self.state, {"core_type": "194mm steel"})
|
||
self.assertEqual(result["status"], "success")
|
||
self.assertEqual(result["effective_inputs"]["core_diameter_mm"]["value"], 194.0)
|
||
self.assertGreater(
|
||
result["calculation"]["average_diameter_mm"], 845.8528172323103
|
||
)
|
||
|
||
def test_ambiguous_or_unknown_core_change_never_guesses(self):
|
||
ambiguous = modify_calculation(self.state, {"core_type": "steel"})
|
||
unknown = modify_calculation(self.state, {"core_type": "cardboard"})
|
||
self.assertEqual(ambiguous["status"], "needs_clarification")
|
||
self.assertEqual(unknown["status"], "needs_clarification")
|
||
self.assertIn("core_type", ambiguous["ambiguous"])
|
||
self.assertIn("core_type", unknown["ambiguous"])
|
||
|
||
def test_caller_result_fields_are_rejected_from_state(self):
|
||
result = modify_calculation(
|
||
{
|
||
"request": self.state.request.to_dict(),
|
||
"calculation": {"average_diameter_mm": 1},
|
||
},
|
||
{"roll_length_m": 80},
|
||
)
|
||
|
||
self.assertEqual(result["status"], "invalid_parameter")
|
||
self.assertIn("unknown state fields", result["invalid"][0]["message"])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|