Expose product length calculation through MCP
This commit is contained in:
@@ -118,9 +118,11 @@ Authentication is implemented with `Flask-HTTPAuth`. The current code checks `BE
|
||||
|
||||
`mcp_server.py` provides a local stdio-only Model Context Protocol server. It is
|
||||
a thin adapter over the same domain services used by Flask: article lookup,
|
||||
direct roll calculation, material-weight calculation, and transport analysis.
|
||||
direct roll calculation, product-length calculation, material-weight calculation,
|
||||
and transport analysis.
|
||||
The available PoC tools are `get_article`, `search_articles`,
|
||||
`calculate_material_weight`, `calculate_roll_diameter`, and
|
||||
`calculate_material_weight`, `calculate_product_length`,
|
||||
`calculate_roll_diameter`, and
|
||||
`analyze_transport_capacity`.
|
||||
|
||||
Install the pinned dependencies, including `mcp==1.26.0`, in the existing
|
||||
@@ -132,7 +134,7 @@ environment, then start it with:
|
||||
```
|
||||
|
||||
The PoC has no MCP resources, HTTP/SSE transport, remote authentication, or
|
||||
dedicated product-length or target-length calculation tools, or extrapolation.
|
||||
dedicated target-length calculation tool or extrapolation support.
|
||||
|
||||
Tool calling semantics:
|
||||
|
||||
@@ -145,6 +147,11 @@ Tool calling semantics:
|
||||
- `calculate_material_weight` calculates material-only weight from roll length,
|
||||
width, and area weight. It requires no core diameter, does not infer one, and
|
||||
cannot return a roll diameter or a core-inclusive total weight.
|
||||
- `calculate_product_length` calculates required roll length from a target outer
|
||||
diameter, core diameter, and material thickness. Its optional thickness
|
||||
stddev returns minimum/average/maximum length ranges; optional width and area
|
||||
weight return material-weight ranges. It does not infer missing article or
|
||||
material inputs.
|
||||
- `calculate_roll_diameter` uses `roll_length_m` for the material length on one
|
||||
roll. Diameter and thickness inputs are in mm; `width_m` is in m;
|
||||
`area_weight_g_m2` is in g/m². It requires a known positive core diameter;
|
||||
@@ -382,7 +389,11 @@ The direct calculation tab supports multiple modes via mode buttons:
|
||||
- Product length from core diameter, material thickness, and roll diameter.
|
||||
- Product length for a target diameter.
|
||||
|
||||
The direct roll-diameter mode posts raw decision inputs to `/api/calculations/roll`. The authoritative implementation is `calculate_roll()` in `roll_calculation.py`; the template only renders its result.
|
||||
The direct roll-diameter mode posts raw decision inputs to `/api/calculations/roll`.
|
||||
The Product Length mode posts to `/api/calculations/product-length`. The
|
||||
authoritative implementations are `calculate_roll()` and
|
||||
`calculate_product_length()` in `roll_calculation.py`; the template only renders
|
||||
their results.
|
||||
|
||||
The four core preset buttons are rendered from the canonical ordered `CORE_PRESETS` tuple in `core_presets.py`. Headless and conversational core resolution uses the same tuple and its finite German/English aliases. Generic steel and PVC family names remain ambiguous because the current UI contains two presets for each family.
|
||||
|
||||
|
||||
@@ -32,7 +32,11 @@ from pdf_report import (
|
||||
render_roll_report,
|
||||
safe_report_filename,
|
||||
)
|
||||
from roll_calculation import calculate_roll, modify_calculation
|
||||
from roll_calculation import (
|
||||
calculate_product_length,
|
||||
calculate_roll,
|
||||
modify_calculation,
|
||||
)
|
||||
from transport_calculation import analyze_transport, transport_presets
|
||||
|
||||
app = Flask(__name__)
|
||||
@@ -199,6 +203,40 @@ def create_roll_calculation():
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@app.route("/api/calculations/product-length", methods=["POST"])
|
||||
@auth.login_required
|
||||
def create_product_length_calculation():
|
||||
"""Calculate deterministic product length for a target roll diameter."""
|
||||
payload = request.get_json(silent=True)
|
||||
if not isinstance(payload, dict):
|
||||
result = {
|
||||
"status": "invalid_parameter",
|
||||
"invalid": [{"field": "request", "message": "JSON object required"}],
|
||||
}
|
||||
else:
|
||||
allowed = {
|
||||
"target_roll_diameter_mm",
|
||||
"core_diameter_mm",
|
||||
"thickness_mm",
|
||||
"thickness_stddev_mm",
|
||||
"width_m",
|
||||
"area_weight_g_m2",
|
||||
}
|
||||
unknown = sorted(set(payload) - allowed)
|
||||
if unknown:
|
||||
result = {
|
||||
"status": "invalid_parameter",
|
||||
"invalid": [{
|
||||
"field": "request",
|
||||
"message": "unknown request fields: " + ", ".join(unknown),
|
||||
}],
|
||||
}
|
||||
else:
|
||||
result = calculate_product_length(**payload)
|
||||
log_access(auth.current_user(), "/api/calculations/product-length", "POST")
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@app.route("/api/calculations/roll/modify", methods=["POST"])
|
||||
@auth.login_required
|
||||
def modify_roll_calculation():
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
from rollcalc_mcp_tools import (
|
||||
analyze_transport_capacity_result,
|
||||
calculate_material_weight_result,
|
||||
calculate_product_length_result,
|
||||
calculate_roll_diameter_result,
|
||||
get_article_result,
|
||||
search_articles_result,
|
||||
@@ -46,6 +47,25 @@ def create_server() -> Any:
|
||||
area_weight_g_m2=area_weight_g_m2,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def calculate_product_length(
|
||||
target_roll_diameter_mm: float,
|
||||
core_diameter_mm: float,
|
||||
thickness_mm: float,
|
||||
thickness_stddev_mm: float | None = None,
|
||||
width_m: float | None = None,
|
||||
area_weight_g_m2: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Calculate required product/roll length for a target outer roll diameter. target_roll_diameter_mm, core_diameter_mm, and thickness_mm are required and expressed in mm. Use exact article master-data values obtained through get_article when available; do not infer a missing core diameter, thickness, width, area weight, or article. thickness_stddev_mm returns deterministic minimum, average, and maximum length ranges. When both width_m (m) and area_weight_g_m2 (g/m²) are supplied, the result also includes corresponding material-weight ranges. This tool calculates length from target diameter; use calculate_roll_diameter for the reverse direction."""
|
||||
return calculate_product_length_result(
|
||||
target_roll_diameter_mm=target_roll_diameter_mm,
|
||||
core_diameter_mm=core_diameter_mm,
|
||||
thickness_mm=thickness_mm,
|
||||
thickness_stddev_mm=thickness_stddev_mm,
|
||||
width_m=width_m,
|
||||
area_weight_g_m2=area_weight_g_m2,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def calculate_roll_diameter(
|
||||
roll_length_m: float | None = None,
|
||||
|
||||
@@ -431,6 +431,129 @@ def _material_weight_kg(
|
||||
return area_weight_g_m2 * roll_length_m * width_m / 1000
|
||||
|
||||
|
||||
def calculate_product_length(
|
||||
*,
|
||||
target_roll_diameter_mm: Any = None,
|
||||
core_diameter_mm: Any = None,
|
||||
thickness_mm: Any = None,
|
||||
thickness_stddev_mm: Any = None,
|
||||
width_m: Any = None,
|
||||
area_weight_g_m2: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Calculate product-length ranges for a target outer roll diameter."""
|
||||
required = {
|
||||
"target_roll_diameter_mm": target_roll_diameter_mm,
|
||||
"core_diameter_mm": core_diameter_mm,
|
||||
"thickness_mm": thickness_mm,
|
||||
}
|
||||
invalid = []
|
||||
normalized = {}
|
||||
for field, value in required.items():
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
invalid.append({"field": field, "message": "must be a number"})
|
||||
continue
|
||||
number = float(value)
|
||||
if not math.isfinite(number) or number <= 0:
|
||||
invalid.append(
|
||||
{"field": field, "message": "must be greater than zero"}
|
||||
)
|
||||
continue
|
||||
normalized[field] = number
|
||||
|
||||
if thickness_stddev_mm is None:
|
||||
thickness_stddev = 0.0
|
||||
elif isinstance(thickness_stddev_mm, bool) or not isinstance(
|
||||
thickness_stddev_mm, (int, float)
|
||||
):
|
||||
invalid.append(
|
||||
{"field": "thickness_stddev_mm", "message": "must be a number"}
|
||||
)
|
||||
thickness_stddev = 0.0
|
||||
else:
|
||||
thickness_stddev = float(thickness_stddev_mm)
|
||||
if not math.isfinite(thickness_stddev) or thickness_stddev < 0:
|
||||
invalid.append(
|
||||
{
|
||||
"field": "thickness_stddev_mm",
|
||||
"message": "must be zero or greater",
|
||||
}
|
||||
)
|
||||
if invalid:
|
||||
return {"status": "invalid_parameter", "invalid": invalid}
|
||||
|
||||
target = normalized["target_roll_diameter_mm"]
|
||||
core = normalized["core_diameter_mm"]
|
||||
thickness = normalized["thickness_mm"]
|
||||
if target <= core:
|
||||
return {
|
||||
"status": "invalid_parameter",
|
||||
"invalid": [
|
||||
{
|
||||
"field": "target_roll_diameter_mm",
|
||||
"message": "must be greater than core_diameter_mm",
|
||||
}
|
||||
],
|
||||
}
|
||||
minimum_thickness = thickness - (2 * thickness_stddev)
|
||||
if minimum_thickness <= 0:
|
||||
return {
|
||||
"status": "invalid_parameter",
|
||||
"invalid": [
|
||||
{
|
||||
"field": "thickness_stddev_mm",
|
||||
"message": "minus two standard deviations must remain positive",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
length_factor = math.pi * (target**2 - core**2) / 4000
|
||||
minimum_length = length_factor / (thickness + (2 * thickness_stddev))
|
||||
average_length = length_factor / thickness
|
||||
maximum_length = length_factor / minimum_thickness
|
||||
lengths = (minimum_length, average_length, maximum_length)
|
||||
if not all(math.isfinite(value) and value > 0 for value in lengths):
|
||||
return {
|
||||
"status": "invalid_parameter",
|
||||
"invalid": [
|
||||
{
|
||||
"field": "request",
|
||||
"message": "values are outside the supported calculation range",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
normalized["thickness_stddev_mm"] = thickness_stddev
|
||||
material_weights = None
|
||||
if width_m is not None and area_weight_g_m2 is not None:
|
||||
weight_result = calculate_material_weight(
|
||||
roll_length_m=average_length,
|
||||
width_m=width_m,
|
||||
area_weight_g_m2=area_weight_g_m2,
|
||||
)
|
||||
if weight_result["status"] != "success":
|
||||
return weight_result
|
||||
width = weight_result["inputs"]["width_m"]
|
||||
area_weight = weight_result["inputs"]["area_weight_g_m2"]
|
||||
normalized["width_m"] = width
|
||||
normalized["area_weight_g_m2"] = area_weight
|
||||
material_weights = {
|
||||
"minimum_kg": _material_weight_kg(minimum_length, width, area_weight),
|
||||
"average_kg": weight_result["material_weight_kg"],
|
||||
"maximum_kg": _material_weight_kg(maximum_length, width, area_weight),
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"inputs": normalized,
|
||||
"calculation": {
|
||||
"minimum_product_length_m": minimum_length,
|
||||
"average_product_length_m": average_length,
|
||||
"maximum_product_length_m": maximum_length,
|
||||
"material_weights_kg": material_weights,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _canonical_name(value: str) -> str:
|
||||
value = value.casefold().replace("×", " x ")
|
||||
tokens = re.findall(r"\d+(?:[.,]\d+)?|[^\W\d_]+", value)
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
from roll_calculation import (
|
||||
ArticleRepository,
|
||||
calculate_material_weight,
|
||||
calculate_product_length,
|
||||
calculate_roll,
|
||||
get_article,
|
||||
transport_roll_inputs_from_roll_calculation,
|
||||
@@ -41,6 +42,26 @@ def calculate_material_weight_result(
|
||||
)
|
||||
|
||||
|
||||
def calculate_product_length_result(
|
||||
*,
|
||||
target_roll_diameter_mm: float,
|
||||
core_diameter_mm: float,
|
||||
thickness_mm: float,
|
||||
thickness_stddev_mm: float | None = None,
|
||||
width_m: float | None = None,
|
||||
area_weight_g_m2: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Delegate target-diameter product-length calculation to the domain."""
|
||||
return calculate_product_length(
|
||||
target_roll_diameter_mm=target_roll_diameter_mm,
|
||||
core_diameter_mm=core_diameter_mm,
|
||||
thickness_mm=thickness_mm,
|
||||
thickness_stddev_mm=thickness_stddev_mm,
|
||||
width_m=width_m,
|
||||
area_weight_g_m2=area_weight_g_m2,
|
||||
)
|
||||
|
||||
|
||||
def calculate_roll_diameter_result(
|
||||
*,
|
||||
article_number: str | None = None,
|
||||
|
||||
@@ -1080,6 +1080,16 @@ async function requestRollCalculation(calculationRequest) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function requestProductLengthCalculation(calculationRequest) {
|
||||
const response = await fetch('/api/calculations/product-length', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(calculationRequest)
|
||||
});
|
||||
if (!response.ok) throw new Error('Product-length calculation service failed');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
document.getElementById('d-calc').addEventListener('click', async () => {
|
||||
invalidatePdfReport();
|
||||
const mode = document.querySelector('.mode-btn.active')?.dataset.mode || 'diameter';
|
||||
@@ -1135,11 +1145,36 @@ document.getElementById('d-calc').addEventListener('click', async () => {
|
||||
renderAuthoritativeForkliftResult(authoritativeResult);
|
||||
} else if (mode === 'length') {
|
||||
if (!d || !t || !D) { alert('Please enter d, t, and D.'); return; }
|
||||
const L_calc = (Math.PI / (4 * t)) * (D*D - d*d) / 1000;
|
||||
try {
|
||||
authoritativeResult = await requestProductLengthCalculation({
|
||||
target_roll_diameter_mm: D,
|
||||
core_diameter_mm: d,
|
||||
thickness_mm: t,
|
||||
thickness_stddev_mm: tol,
|
||||
width_m: numericInput('d-wi'),
|
||||
area_weight_g_m2: numericInput('d-aw')
|
||||
});
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
return;
|
||||
}
|
||||
if (authoritativeResult.status !== 'success') {
|
||||
const issue = authoritativeResult.invalid?.[0]?.message;
|
||||
alert(issue || 'Product-length calculation failed.');
|
||||
return;
|
||||
}
|
||||
const calculation = authoritativeResult.calculation;
|
||||
const L_calc = calculation.average_product_length_m;
|
||||
resLabel.textContent = 'Product Length (L)';
|
||||
resVal.textContent = L_calc.toFixed(2) + ' m';
|
||||
if (tol) {
|
||||
resRange.textContent = '−2σ: ' + calculation.minimum_product_length_m.toFixed(2) + ' m | +2σ: ' + calculation.maximum_product_length_m.toFixed(2) + ' m';
|
||||
} else {
|
||||
resRange.textContent = '';
|
||||
}
|
||||
document.getElementById('d-le').value = L_calc.toFixed(2);
|
||||
const materialWeight = calculation.material_weights_kg?.average_kg;
|
||||
document.getElementById('d-wo').value = materialWeight === undefined ? '' : materialWeight.toFixed(1);
|
||||
} else if (mode === 'target') {
|
||||
const wi = parseFloat(document.getElementById('d-wi').value) || 0;
|
||||
const aw = parseFloat(document.getElementById('d-aw').value) || 0;
|
||||
|
||||
@@ -63,6 +63,27 @@ 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_product_length_endpoint_uses_the_domain_calculation(self):
|
||||
response = self.client.post(
|
||||
"/api/calculations/product-length",
|
||||
json={
|
||||
"target_roll_diameter_mm": 600,
|
||||
"core_diameter_mm": 140,
|
||||
"thickness_mm": 4.311611,
|
||||
"thickness_stddev_mm": 0.249999,
|
||||
"width_m": 6.0,
|
||||
"area_weight_g_m2": 495.395833,
|
||||
},
|
||||
headers=self.headers,
|
||||
)
|
||||
result = response.get_json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertAlmostEqual(
|
||||
result["calculation"]["average_product_length_m"], 62.0068774341
|
||||
)
|
||||
|
||||
def test_transport_endpoint_delegates_to_shared_domain_service(self):
|
||||
payload = {
|
||||
"length_m": 10,
|
||||
|
||||
@@ -8,12 +8,14 @@ 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_material_weight_result,
|
||||
calculate_product_length_result,
|
||||
calculate_roll_diameter_result,
|
||||
get_article_result,
|
||||
search_articles_result,
|
||||
@@ -87,6 +89,23 @@ class McpAdapterTests(unittest.TestCase):
|
||||
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 (
|
||||
{
|
||||
@@ -178,6 +197,7 @@ class McpAdapterTests(unittest.TestCase):
|
||||
"get_article",
|
||||
"search_articles",
|
||||
"calculate_material_weight",
|
||||
"calculate_product_length",
|
||||
"calculate_roll_diameter",
|
||||
"analyze_transport_capacity",
|
||||
],
|
||||
@@ -188,6 +208,7 @@ class McpAdapterTests(unittest.TestCase):
|
||||
tools = instance._tool_manager._tools
|
||||
search_tool = tools["search_articles"]
|
||||
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
|
||||
@@ -202,6 +223,19 @@ class McpAdapterTests(unittest.TestCase):
|
||||
["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"]
|
||||
@@ -294,6 +328,16 @@ class McpAdapterTests(unittest.TestCase):
|
||||
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()
|
||||
|
||||
@@ -9,6 +9,7 @@ from roll_calculation import (
|
||||
_canonical_name,
|
||||
article_production_site,
|
||||
calculate_material_weight,
|
||||
calculate_product_length,
|
||||
calculate_roll,
|
||||
get_article,
|
||||
modify_calculation,
|
||||
@@ -212,6 +213,37 @@ class RollCalculationTests(unittest.TestCase):
|
||||
item["field"] for item in result["invalid"]
|
||||
])
|
||||
|
||||
def test_product_length_characterizes_article_218500_with_ranges_and_weight(self):
|
||||
result = calculate_product_length(
|
||||
target_roll_diameter_mm=600,
|
||||
core_diameter_mm=140,
|
||||
thickness_mm=4.311611,
|
||||
thickness_stddev_mm=0.249999,
|
||||
width_m=6.0,
|
||||
area_weight_g_m2=495.395833,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
calculation = result["calculation"]
|
||||
self.assertAlmostEqual(calculation["minimum_product_length_m"], 55.5634372661)
|
||||
self.assertAlmostEqual(calculation["average_product_length_m"], 62.0068774341)
|
||||
self.assertAlmostEqual(calculation["maximum_product_length_m"], 70.1407868061)
|
||||
self.assertAlmostEqual(
|
||||
calculation["material_weights_kg"]["average_kg"], 184.3076921892
|
||||
)
|
||||
|
||||
def test_product_length_rejects_target_diameter_not_larger_than_core(self):
|
||||
result = calculate_product_length(
|
||||
target_roll_diameter_mm=140,
|
||||
core_diameter_mm=140,
|
||||
thickness_mm=4,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "invalid_parameter")
|
||||
self.assertEqual(
|
||||
result["invalid"][0]["field"], "target_roll_diameter_mm"
|
||||
)
|
||||
|
||||
def test_diameter_only_calculation_does_not_require_width(self):
|
||||
result = self.calculate_fixture(
|
||||
{
|
||||
|
||||
@@ -17,6 +17,11 @@ class TemplatePdfIntegrationTests(unittest.TestCase):
|
||||
self.assertIn("fetch('/api/calculations/roll'", self.template)
|
||||
self.assertIn("calculation.average_diameter_mm", self.template)
|
||||
self.assertIn("calculation.effective_roll_length_m", self.template)
|
||||
|
||||
def test_product_length_ui_uses_the_shared_domain_endpoint(self):
|
||||
self.assertIn("fetch('/api/calculations/product-length'", self.template)
|
||||
self.assertIn("calculation.average_product_length_m", self.template)
|
||||
self.assertNotIn("const L_calc = (Math.PI / (4 * t))", self.template)
|
||||
self.assertNotIn("function calculateDiameterResult", self.template)
|
||||
|
||||
def test_optional_weight_requires_both_weight_inputs(self):
|
||||
|
||||
Reference in New Issue
Block a user