import json import math import unittest from roll_calculation import ( ArticleRepository, CalculationRequest, CalculationState, _canonical_name, calculate_roll, get_article, modify_calculation, transport_roll_inputs_from_roll_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_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"] 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_transport_roll_inputs_match_browser_average_and_display_rounding(self): result = calculate_roll(representative_request()) self.assertEqual( transport_roll_inputs_from_roll_calculation(result), { "roll_diameter_mm": 845.9, "core_diameter_mm": 150.0, "roll_width_m": 5.8, "roll_weight_kg": 512.5, "product_length_m": 50.0, }, ) def test_transport_roll_inputs_use_browser_equivalent_half_rounding(self): result = { "status": "success", "calculation": { "average_diameter_mm": 1000.25, "roll_weight_kg": 12.25, "effective_roll_length_m": 10.0, }, "effective_inputs": { "core_diameter_mm": {"value": 150.0}, "width_m": {"value": 2.0}, }, } inputs = transport_roll_inputs_from_roll_calculation(result) self.assertEqual(inputs["roll_diameter_mm"], 1000.3) self.assertEqual(inputs["roll_weight_kg"], 12.3) def test_transport_roll_inputs_reject_unsuccessful_or_incomplete_results(self): for result in ( {"status": "needs_clarification"}, {"status": "success", "calculation": {}, "effective_inputs": {}}, ): with self.subTest(result=result): with self.assertRaises(ValueError): transport_roll_inputs_from_roll_calculation(result) 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()