Files
RollCalcPython/tests/test_roll_calculation.py
T

524 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()