487 lines
19 KiB
Python
487 lines
19 KiB
Python
import copy
|
|
import inspect
|
|
import sys
|
|
import types
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
import mcp_server
|
|
from roll_calculation import (
|
|
ArticleRepository,
|
|
calculate_material_weight,
|
|
calculate_product_length,
|
|
calculate_roll,
|
|
get_article,
|
|
)
|
|
from rollcalc_mcp_tools import (
|
|
analyze_transport_capacity_result,
|
|
calculate_machine_max_product_length_result,
|
|
check_production_feasibility_result,
|
|
calculate_material_weight_result,
|
|
calculate_product_length_result,
|
|
calculate_roll_diameter_result,
|
|
get_article_result,
|
|
get_machine_result,
|
|
project_machine_max_product_length_result,
|
|
search_articles_result,
|
|
)
|
|
from machine_constraints import (
|
|
calculate_machine_max_product_length,
|
|
check_production_feasibility,
|
|
get_machine,
|
|
)
|
|
from transport_calculation import analyze_transport, transport_presets
|
|
|
|
|
|
class _FakeFastMCP:
|
|
def __init__(self, name):
|
|
self.name = name
|
|
self.tools = []
|
|
|
|
def tool(self):
|
|
def register(function):
|
|
self.tools.append(function)
|
|
return function
|
|
return register
|
|
|
|
|
|
class McpAdapterTests(unittest.TestCase):
|
|
def test_get_article_delegates_to_the_domain_for_success_not_found_and_conflict(self):
|
|
for arguments in (
|
|
{"article_number": "146900"},
|
|
{"article_number": "does-not-exist"},
|
|
{"article_number": "146900", "article_name_hint": "Other product"},
|
|
):
|
|
with self.subTest(arguments=arguments):
|
|
self.assertEqual(
|
|
get_article_result(**arguments),
|
|
get_article(**arguments),
|
|
)
|
|
|
|
def test_search_articles_delegates_to_domain_and_preserves_candidates(self):
|
|
query = "Bfix NSP 4900, 5,00 x 40 m"
|
|
|
|
result = search_articles_result(query)
|
|
|
|
self.assertEqual(result, ArticleRepository.load().search(query))
|
|
self.assertEqual(result["status"], "search_results")
|
|
self.assertEqual(
|
|
[candidate["article_number"] for candidate in result["candidates"]],
|
|
["180205", "8180205", "182815", "206900"],
|
|
)
|
|
self.assertEqual(
|
|
result["candidates"][1]["production_site"], "Malaysia"
|
|
)
|
|
|
|
def test_search_articles_preserves_ambiguous_and_not_found_domain_results(self):
|
|
ambiguous = search_articles_result("Bentofix NSP 4900")
|
|
missing = search_articles_result("not a RollCalc product")
|
|
|
|
self.assertGreater(ambiguous["total_matches"], 1)
|
|
self.assertGreater(len(ambiguous["candidates"]), 1)
|
|
self.assertEqual(missing, {
|
|
"status": "search_not_found",
|
|
"query": "not a RollCalc product",
|
|
"total_matches": 0,
|
|
"candidates": [],
|
|
})
|
|
|
|
def test_machine_adapters_delegate_to_the_shared_domain(self):
|
|
arguments = {
|
|
"machine": "B1",
|
|
"roll_weight_kg": 2500.0,
|
|
"average_diameter_mm": 876.0,
|
|
"maximum_diameter_mm": 876.1,
|
|
"core_diameter_mm": 168.0,
|
|
"product_width_m": 5.0,
|
|
}
|
|
self.assertEqual(get_machine_result("B1"), get_machine("B1"))
|
|
self.assertEqual(
|
|
check_production_feasibility_result(**arguments),
|
|
check_production_feasibility(arguments),
|
|
)
|
|
self.assertEqual(
|
|
check_production_feasibility_result(**arguments)["feasibility"],
|
|
"feasible_with_warnings",
|
|
)
|
|
|
|
def test_machine_maximum_length_adapter_projects_success_without_mutating_domain(self):
|
|
arguments = {
|
|
"machine": "K7",
|
|
"core_diameter_mm": 133.0,
|
|
"thickness_mm": 4.311611,
|
|
"thickness_stddev_mm": 0.249999,
|
|
"product_width_m": 6.0,
|
|
"area_weight_g_m2": 495.395833,
|
|
}
|
|
domain_result = calculate_machine_max_product_length(**arguments)
|
|
domain_before_projection = copy.deepcopy(domain_result)
|
|
|
|
result = project_machine_max_product_length_result(domain_result)
|
|
|
|
self.assertEqual(domain_result, domain_before_projection)
|
|
self.assertIn("final_feasibility", domain_result)
|
|
self.assertNotIn("final_feasibility", result)
|
|
self.assertEqual(
|
|
calculate_machine_max_product_length_result(**arguments), result
|
|
)
|
|
self.assertEqual(result["machine"], {"id": "k7", "name": "K7"})
|
|
self.assertEqual(result["weight_scope"], "material_only")
|
|
self.assertEqual(result["machine_limits"], {
|
|
"maximum_roll_diameter_mm": 1100.0,
|
|
"maximum_material_roll_weight_kg": 700.0,
|
|
})
|
|
self.assertEqual(result["production_maximums"], {
|
|
"nominal_length_m": 217.1900177447768,
|
|
"nominal_governing_constraints": ["roll_diameter_nominal"],
|
|
"conservative_length_m": 194.62073281485985,
|
|
"conservative_governing_constraints": ["roll_diameter_conservative"],
|
|
})
|
|
self.assertEqual(result["material_weights_kg"], {
|
|
"at_nominal_production_maximum": 645.570178559751,
|
|
"at_conservative_production_maximum": 578.4858003113276,
|
|
})
|
|
self.assertEqual(result["warnings"], {"nominal": ["roll_diameter_mm"]})
|
|
|
|
def test_machine_maximum_length_adapter_preserves_non_success_diagnostics(self):
|
|
base = {
|
|
"machine": "K7",
|
|
"core_diameter_mm": 133.0,
|
|
"thickness_mm": 4.311611,
|
|
"thickness_stddev_mm": 0.249999,
|
|
"product_width_m": 6.0,
|
|
"area_weight_g_m2": 495.395833,
|
|
}
|
|
for changes, status in (
|
|
({"core_diameter_mm": 194.0}, "incompatible"),
|
|
({"product_width_m": 3.0}, "incompatible"),
|
|
(
|
|
{"product_width_m": None, "area_weight_g_m2": None},
|
|
"needs_clarification",
|
|
),
|
|
({"thickness_mm": -1.0}, "invalid_parameter"),
|
|
({"machine": "not configured"}, "machine_not_found"),
|
|
):
|
|
with self.subTest(changes=changes):
|
|
arguments = {**base, **changes}
|
|
domain_result = calculate_machine_max_product_length(**arguments)
|
|
adapter_result = calculate_machine_max_product_length_result(**arguments)
|
|
self.assertEqual(adapter_result, domain_result)
|
|
self.assertEqual(adapter_result["status"], status)
|
|
|
|
missing = calculate_machine_max_product_length_result(
|
|
**{**base, "product_width_m": None, "area_weight_g_m2": None}
|
|
)
|
|
self.assertEqual(
|
|
missing["missing_required_inputs"],
|
|
["product_width_m", "area_weight_g_m2"],
|
|
)
|
|
core = calculate_machine_max_product_length_result(
|
|
**{**base, "core_diameter_mm": 194.0}
|
|
)
|
|
self.assertEqual(
|
|
core["failed_compatibility_constraints"],
|
|
["allowed_core_diameters_mm"],
|
|
)
|
|
width = calculate_machine_max_product_length_result(
|
|
**{**base, "product_width_m": 3.0}
|
|
)
|
|
self.assertEqual(width["failed_compatibility_constraints"], ["product_width_m"])
|
|
|
|
def test_material_weight_adapter_delegates_without_a_core_diameter(self):
|
|
arguments = {
|
|
"roll_length_m": 60.0,
|
|
"width_m": 5.0,
|
|
"area_weight_g_m2": 4850.079676,
|
|
}
|
|
|
|
result = calculate_material_weight_result(**arguments)
|
|
|
|
self.assertEqual(result, calculate_material_weight(**arguments))
|
|
self.assertEqual(result["weight_scope"], "material_only")
|
|
self.assertAlmostEqual(result["material_weight_kg"], 1455.0239028)
|
|
|
|
def test_product_length_adapter_delegates_with_article_218500_values(self):
|
|
arguments = {
|
|
"target_roll_diameter_mm": 600.0,
|
|
"core_diameter_mm": 140.0,
|
|
"thickness_mm": 4.311611,
|
|
"thickness_stddev_mm": 0.249999,
|
|
"width_m": 6.0,
|
|
"area_weight_g_m2": 495.395833,
|
|
}
|
|
|
|
result = calculate_product_length_result(**arguments)
|
|
|
|
self.assertEqual(result, calculate_product_length(**arguments))
|
|
self.assertAlmostEqual(
|
|
result["calculation"]["average_product_length_m"], 62.0068774341
|
|
)
|
|
|
|
def test_calculate_roll_diameter_delegates_for_normal_weighted_and_invalid_cases(self):
|
|
for arguments in (
|
|
{
|
|
"article_number": "146900",
|
|
"roll_length_m": 50.0,
|
|
"core_diameter_mm": 150.0,
|
|
},
|
|
{
|
|
"article_number": "146900",
|
|
"roll_length_m": 50.0,
|
|
"core_diameter_mm": 150.0,
|
|
"width_m": 5.8,
|
|
"area_weight_g_m2": 1767.2,
|
|
},
|
|
{"roll_length_m": -1.0, "core_diameter_mm": 150.0, "thickness_mm": 2.0},
|
|
):
|
|
with self.subTest(arguments=arguments):
|
|
actual = calculate_roll_diameter_result(**arguments)
|
|
expected = calculate_roll({**arguments, "include_roll_weight": True})
|
|
if expected["status"] == "success":
|
|
self.assertEqual(
|
|
actual["transport_roll_inputs"]["roll_diameter_mm"],
|
|
845.9,
|
|
)
|
|
del actual["transport_roll_inputs"]
|
|
self.assertEqual(actual, expected)
|
|
|
|
def test_roll_calculation_adapter_always_requests_weight_from_the_domain(self):
|
|
result = calculate_roll_diameter_result(
|
|
article_number="114030",
|
|
roll_length_m=15.0,
|
|
core_diameter_mm=100.0,
|
|
)
|
|
|
|
self.assertEqual(result["status"], "success")
|
|
self.assertIsNotNone(result["calculation"]["roll_weight_kg"])
|
|
|
|
def test_roll_calculation_adapter_uses_shared_transport_input_helper(self):
|
|
expected_inputs = {"roll_diameter_mm": 123.4}
|
|
with patch(
|
|
"rollcalc_mcp_tools.transport_roll_inputs_from_roll_calculation",
|
|
return_value=expected_inputs,
|
|
) as helper:
|
|
result = calculate_roll_diameter_result(
|
|
article_number="114030",
|
|
roll_length_m=15.0,
|
|
core_diameter_mm=100.0,
|
|
)
|
|
|
|
helper.assert_called_once()
|
|
self.assertEqual(result["transport_roll_inputs"], expected_inputs)
|
|
|
|
def test_transport_tool_delegates_to_domain_with_lkw_sattelzug_preset(self):
|
|
arguments = {
|
|
"transport_preset": "lkw_sattelzug",
|
|
"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,
|
|
}
|
|
|
|
self.assertEqual(
|
|
analyze_transport_capacity_result(**arguments),
|
|
analyze_transport(arguments),
|
|
)
|
|
result = analyze_transport_capacity_result(**arguments)
|
|
self.assertEqual(result["status"], "success")
|
|
self.assertEqual(result["analysis"]["transport_preset"], "lkw_sattelzug")
|
|
self.assertEqual(result["analysis"]["final_rolls"], 24)
|
|
self.assertEqual(result["analysis"]["limiting"]["name"], "Weight")
|
|
|
|
def test_server_registers_the_existing_tools_and_article_search(self):
|
|
mcp = types.ModuleType("mcp")
|
|
server = types.ModuleType("mcp.server")
|
|
fastmcp = types.ModuleType("mcp.server.fastmcp")
|
|
fastmcp.FastMCP = _FakeFastMCP
|
|
with patch.dict(sys.modules, {
|
|
"mcp": mcp,
|
|
"mcp.server": server,
|
|
"mcp.server.fastmcp": fastmcp,
|
|
}):
|
|
instance = mcp_server.create_server()
|
|
|
|
self.assertEqual(instance.name, "RollCalc")
|
|
self.assertEqual(
|
|
[tool.__name__ for tool in instance.tools],
|
|
[
|
|
"get_article",
|
|
"search_articles",
|
|
"get_machine",
|
|
"check_production_feasibility",
|
|
"calculate_machine_max_product_length",
|
|
"calculate_material_weight",
|
|
"calculate_product_length",
|
|
"calculate_roll_diameter",
|
|
"analyze_transport_capacity",
|
|
],
|
|
)
|
|
|
|
def test_server_schema_exposes_weight_and_transport_chaining_contract(self):
|
|
instance = mcp_server.create_server()
|
|
tools = instance._tool_manager._tools
|
|
search_tool = tools["search_articles"]
|
|
machine_tool = tools["get_machine"]
|
|
feasibility_tool = tools["check_production_feasibility"]
|
|
maximum_tool = tools["calculate_machine_max_product_length"]
|
|
material_weight_tool = tools["calculate_material_weight"]
|
|
product_length_tool = tools["calculate_product_length"]
|
|
roll_schema = tools["calculate_roll_diameter"].parameters
|
|
transport_tool = tools["analyze_transport_capacity"]
|
|
transport_schema = transport_tool.parameters
|
|
|
|
self.assertEqual(search_tool.parameters["required"], ["query"])
|
|
self.assertEqual(machine_tool.parameters["required"], ["machine"])
|
|
self.assertEqual(
|
|
feasibility_tool.parameters["required"], ["machine"]
|
|
)
|
|
self.assertIn("average_diameter_mm", feasibility_tool.parameters["properties"])
|
|
self.assertIn("maximum_diameter_mm", feasibility_tool.parameters["properties"])
|
|
self.assertIn("tool, not the LLM, decides", feasibility_tool.description)
|
|
self.assertIn("never override failed constraints", feasibility_tool.description)
|
|
self.assertIn("warnings", feasibility_tool.description)
|
|
self.assertIn("Missing configured V1 roll inputs", feasibility_tool.description)
|
|
self.assertEqual(
|
|
maximum_tool.parameters["required"],
|
|
["machine", "core_diameter_mm", "thickness_mm"],
|
|
)
|
|
self.assertIn("product_width_m", maximum_tool.parameters["properties"])
|
|
self.assertIn("area_weight_g_m2", maximum_tool.parameters["properties"])
|
|
for text in (
|
|
"Resolve the article first with search_articles/get_article",
|
|
"Do not supply or invent machine limits",
|
|
"rearrange roll or weight formulas",
|
|
"compare candidate limits yourself",
|
|
"nominal and conservative/no-warning production maxima",
|
|
"incompatibility rather than substituting another core or width",
|
|
):
|
|
self.assertIn(text, maximum_tool.description)
|
|
self.assertEqual(
|
|
search_tool.parameters["properties"]["query"]["type"], "string"
|
|
)
|
|
material_weight_schema = material_weight_tool.parameters
|
|
self.assertEqual(
|
|
material_weight_schema["required"],
|
|
["roll_length_m", "width_m", "area_weight_g_m2"],
|
|
)
|
|
self.assertNotIn("core_diameter_mm", material_weight_schema["properties"])
|
|
product_length_schema = product_length_tool.parameters
|
|
self.assertEqual(
|
|
product_length_schema["required"],
|
|
[
|
|
"target_roll_diameter_mm",
|
|
"core_diameter_mm",
|
|
"thickness_mm",
|
|
],
|
|
)
|
|
self.assertEqual(
|
|
product_length_schema["properties"]["target_roll_diameter_mm"]["type"],
|
|
"number",
|
|
)
|
|
|
|
self.assertNotIn("include_roll_weight", roll_schema["properties"])
|
|
preset_schema = transport_schema["properties"]["transport_preset"]
|
|
self.assertEqual(
|
|
preset_schema["enum"],
|
|
[preset.key for preset in transport_presets()],
|
|
)
|
|
self.assertEqual(
|
|
transport_schema["required"],
|
|
[
|
|
"transport_preset",
|
|
"roll_diameter_mm",
|
|
"core_diameter_mm",
|
|
"roll_width_m",
|
|
"roll_weight_kg",
|
|
"product_length_m",
|
|
],
|
|
)
|
|
for field in transport_schema["required"]:
|
|
if field == "transport_preset":
|
|
continue
|
|
self.assertEqual(
|
|
transport_schema["properties"][field]["type"], "number"
|
|
)
|
|
for field in (
|
|
"length_m",
|
|
"width_m",
|
|
"height_m",
|
|
"max_weight_kg",
|
|
"margin_side_m",
|
|
"margin_ceiling_m",
|
|
):
|
|
self.assertNotIn(field, transport_schema["properties"])
|
|
self.assertNotIn(
|
|
field,
|
|
inspect.signature(analyze_transport_capacity_result).parameters,
|
|
)
|
|
description = transport_tool.description
|
|
for text in (
|
|
"Use this tool when the user asks how many rolls fit on a known RollCalc transport type",
|
|
"do not ask the user for vehicle dimensions or payload",
|
|
"LKW-Sattelzug -> lkw_sattelzug",
|
|
"LKW-Tandem -> lkw_tandem",
|
|
"20ft Container -> container_20ft",
|
|
"40ft Container -> container_40ft",
|
|
"40ft High Cube -> container_40ft_hc",
|
|
'transport_preset=\"lkw_sattelzug\"',
|
|
"first call get_article and calculate_roll_diameter",
|
|
"transport_roll_inputs bundle unchanged",
|
|
"Do not choose among minimum, average, or maximum diameter",
|
|
"product_length_m",
|
|
"do not estimate or invent",
|
|
"stateless tool does not recalculate a roll",
|
|
):
|
|
self.assertIn(text, description)
|
|
|
|
search_description = search_tool.description
|
|
for text in (
|
|
"name, family, designation, or descriptive article text",
|
|
"Do not guess or invent an article number",
|
|
"deterministic retrieval candidates from the RollCalc article master data",
|
|
"not product recommendations",
|
|
"total_matches is exactly 1",
|
|
"use that candidate's exact article_number",
|
|
"do NOT select the first or highest-ranked candidate, do not treat ranking as permission to choose",
|
|
"ask the user which article number is intended",
|
|
"do not calculate yet",
|
|
"no matching RollCalc article was found",
|
|
"more specific designation or article number",
|
|
"do not invent an article",
|
|
"production_site=Malaysia is descriptive metadata only",
|
|
"does not imply Bentofix, Bento 2, or any production machine",
|
|
"Ranking/order expresses retrieval relevance only",
|
|
"get_article -> calculate_roll_diameter -> optionally analyze_transport_capacity",
|
|
):
|
|
self.assertIn(text, search_description)
|
|
|
|
material_weight_description = material_weight_tool.description
|
|
for text in (
|
|
"material-only roll weight",
|
|
"does not require, accept, infer, or default a core diameter",
|
|
"never invent a standard core",
|
|
"excludes any core weight",
|
|
"cannot provide roll diameter or a core-inclusive total weight",
|
|
):
|
|
self.assertIn(text, material_weight_description)
|
|
|
|
roll_description = tools["calculate_roll_diameter"].description
|
|
self.assertIn("requires a known positive core diameter", roll_description)
|
|
self.assertIn("do not infer or default one", roll_description)
|
|
self.assertIn("call calculate_material_weight instead", roll_description)
|
|
|
|
product_length_description = product_length_tool.description
|
|
for text in (
|
|
"required product/roll length for a target outer roll diameter",
|
|
"do not infer a missing core diameter, thickness, width, area weight, or article",
|
|
"minimum, average, and maximum length ranges",
|
|
"material-weight ranges",
|
|
"use calculate_roll_diameter for the reverse direction",
|
|
):
|
|
self.assertIn(text, product_length_description)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|