feat: add conversational RollCalc assistant
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import base64
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import app as rollcalc_app
|
||||
|
||||
|
||||
class CalculationApiTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.original_log_file = rollcalc_app.LOG_FILE
|
||||
rollcalc_app.LOG_FILE = os.path.join(self.temp_dir.name, "access.json")
|
||||
rollcalc_app.app.config.update(TESTING=True)
|
||||
self.client = rollcalc_app.app.test_client()
|
||||
credentials = base64.b64encode(b"mtazl:rollcalc").decode("ascii")
|
||||
self.headers = {"Authorization": f"Basic {credentials}"}
|
||||
|
||||
def tearDown(self):
|
||||
rollcalc_app.LOG_FILE = self.original_log_file
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def test_calculation_endpoint_returns_machine_readable_authoritative_result(self):
|
||||
response = self.client.post(
|
||||
"/api/calculations/roll",
|
||||
json={
|
||||
"article_number": "180205",
|
||||
"roll_length_m": 65,
|
||||
"core_diameter_mm": 150,
|
||||
},
|
||||
headers=self.headers,
|
||||
)
|
||||
result = response.get_json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["resolved_article"]["number"], "180205")
|
||||
self.assertEqual(
|
||||
result["provenance"]["calculator"],
|
||||
"roll_calculation.calculate_roll",
|
||||
)
|
||||
|
||||
def test_modification_endpoint_changes_state_and_recalculates(self):
|
||||
response = self.client.post(
|
||||
"/api/calculations/roll/modify",
|
||||
json={
|
||||
"state": {
|
||||
"request": {
|
||||
"article_number": "180205",
|
||||
"roll_length_m": 65,
|
||||
"core_diameter_mm": 150,
|
||||
}
|
||||
},
|
||||
"changes": {"roll_length_m": 80},
|
||||
},
|
||||
headers=self.headers,
|
||||
)
|
||||
result = response.get_json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["state"]["request"]["roll_length_m"], 80.0)
|
||||
self.assertEqual(result["calculation"]["effective_roll_length_m"], 80.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,112 @@
|
||||
import base64
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import app as rollcalc_app
|
||||
from conversation_service import ConversationService, InMemoryConversationStore
|
||||
from ollama_nlu import NLUResponse, OllamaUnavailableError, validate_nlu_payload
|
||||
|
||||
|
||||
class StaticNLUClient:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def interpret(
|
||||
self,
|
||||
message,
|
||||
*,
|
||||
has_state=False,
|
||||
expected_fields=(),
|
||||
current_state=None,
|
||||
):
|
||||
if isinstance(self.payload, Exception):
|
||||
raise self.payload
|
||||
return NLUResponse(
|
||||
interpretation=validate_nlu_payload(self.payload),
|
||||
raw_model_json="raw-json",
|
||||
latency_ms=1.0,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
|
||||
class ConversationApiTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.original_log_file = rollcalc_app.LOG_FILE
|
||||
self.original_service = rollcalc_app.CONVERSATION_SERVICE
|
||||
rollcalc_app.LOG_FILE = os.path.join(self.temp_dir.name, "access.json")
|
||||
rollcalc_app.app.config.update(TESTING=True)
|
||||
self.client = rollcalc_app.app.test_client()
|
||||
credentials = base64.b64encode(b"mtazl:rollcalc").decode("ascii")
|
||||
self.headers = {"Authorization": f"Basic {credentials}"}
|
||||
|
||||
def tearDown(self):
|
||||
rollcalc_app.LOG_FILE = self.original_log_file
|
||||
rollcalc_app.CONVERSATION_SERVICE = self.original_service
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def configure(self, payload):
|
||||
rollcalc_app.CONVERSATION_SERVICE = ConversationService(
|
||||
StaticNLUClient(payload),
|
||||
store=InMemoryConversationStore(),
|
||||
build_info=rollcalc_app.BUILD_INFO,
|
||||
)
|
||||
|
||||
def create_conversation(self):
|
||||
response = self.client.post("/api/conversations", headers=self.headers)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
return response.get_json()["conversation_id"]
|
||||
|
||||
def test_conversation_success_exposes_authoritative_pdf(self):
|
||||
self.configure({
|
||||
"intent": "new_calculation",
|
||||
"article_number": "180205",
|
||||
"article_name_hint": "Bentofix NSP 4900",
|
||||
"roll_length_m": 65.0,
|
||||
"width_m": None,
|
||||
"core_type": "150mm PVC",
|
||||
"core_diameter_mm": None,
|
||||
"include_roll_weight": False,
|
||||
})
|
||||
conversation_id = self.create_conversation()
|
||||
response = self.client.post(
|
||||
f"/api/conversations/{conversation_id}/messages",
|
||||
json={"message": "Berechnen"},
|
||||
headers=self.headers,
|
||||
)
|
||||
payload = response.get_json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(payload["status"], "success")
|
||||
self.assertEqual(
|
||||
payload["result"]["provenance"]["calculator"],
|
||||
"roll_calculation.calculate_roll",
|
||||
)
|
||||
|
||||
pdf = self.client.get(payload["pdf"]["url"], headers=self.headers)
|
||||
self.assertEqual(pdf.status_code, 200)
|
||||
self.assertEqual(pdf.mimetype, "application/pdf")
|
||||
self.assertEqual(pdf.data.count(b"/Type /Page "), 1)
|
||||
calculation = payload["result"]["calculation"]
|
||||
self.assertIn(
|
||||
f"{calculation['average_diameter_mm']:.1f} mm".encode("ascii"),
|
||||
pdf.data,
|
||||
)
|
||||
|
||||
def test_ollama_unavailable_returns_controlled_http_error(self):
|
||||
self.configure(OllamaUnavailableError("offline"))
|
||||
conversation_id = self.create_conversation()
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/conversations/{conversation_id}/messages",
|
||||
json={"message": "Berechnen"},
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertEqual(response.get_json()["status"], "nlu_error")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,852 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from conversation_service import ConversationService, InMemoryConversationStore
|
||||
from ollama_nlu import (
|
||||
NLUResponse,
|
||||
OllamaUnavailableError,
|
||||
NLUValidationError,
|
||||
validate_nlu_payload,
|
||||
)
|
||||
from roll_calculation import calculate_roll, modify_calculation
|
||||
|
||||
|
||||
def new_interpretation(**changes):
|
||||
payload = {
|
||||
"intent": "new_calculation",
|
||||
"article_number": "180205",
|
||||
"article_name_hint": "Bentofix NSP 4900",
|
||||
"roll_length_m": 65.0,
|
||||
"width_m": None,
|
||||
"core_type": "150mm PVC",
|
||||
"core_diameter_mm": None,
|
||||
"include_roll_weight": False,
|
||||
}
|
||||
payload.update(changes)
|
||||
return payload
|
||||
|
||||
|
||||
class QueueNLUClient:
|
||||
def __init__(self, *payloads):
|
||||
self.payloads = list(payloads)
|
||||
self.calls = []
|
||||
|
||||
def interpret(
|
||||
self,
|
||||
message,
|
||||
*,
|
||||
has_state=False,
|
||||
expected_fields=(),
|
||||
current_state=None,
|
||||
):
|
||||
self.calls.append({
|
||||
"message": message,
|
||||
"has_state": has_state,
|
||||
"expected_fields": expected_fields,
|
||||
"current_state": current_state,
|
||||
})
|
||||
payload = self.payloads.pop(0)
|
||||
if isinstance(payload, Exception):
|
||||
raise payload
|
||||
interpretation = validate_nlu_payload(payload)
|
||||
return NLUResponse(
|
||||
interpretation=interpretation,
|
||||
raw_model_json="raw-json",
|
||||
latency_ms=12.34,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
|
||||
class ConversationServiceTests(unittest.TestCase):
|
||||
def create_service(self, *payloads):
|
||||
client = QueueNLUClient(*payloads)
|
||||
service = ConversationService(
|
||||
client,
|
||||
store=InMemoryConversationStore(),
|
||||
build_info={"version": "test"},
|
||||
)
|
||||
return service, client, service.create_conversation()
|
||||
|
||||
def test_valid_new_calculation_calls_the_deterministic_core(self):
|
||||
service, _, conversation_id = self.create_service(new_interpretation())
|
||||
|
||||
with patch(
|
||||
"conversation_service.calculate_roll",
|
||||
wraps=calculate_roll,
|
||||
) as deterministic_calculator:
|
||||
response = service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
self.assertEqual(response["status"], "success")
|
||||
deterministic_calculator.assert_called_once()
|
||||
self.assertEqual(
|
||||
response["result"]["provenance"]["calculator"],
|
||||
"roll_calculation.calculate_roll",
|
||||
)
|
||||
|
||||
def test_follow_up_length_modification_calls_modify_calculation(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_length_m": 80.0},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
with patch(
|
||||
"conversation_service.modify_calculation",
|
||||
wraps=modify_calculation,
|
||||
) as deterministic_modifier:
|
||||
response = service.handle_message(conversation_id, "Mach daraus 80 m.")
|
||||
|
||||
deterministic_modifier.assert_called_once()
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(
|
||||
response["result"]["calculation"]["effective_roll_length_m"],
|
||||
80.0,
|
||||
)
|
||||
self.assertTrue(client.calls[1]["has_state"])
|
||||
|
||||
def test_multi_field_follow_up_recalculates_once_and_creates_new_report(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(core_type="194mm Stahl", core_diameter_mm=194.0),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_length_m": 80.0, "include_roll_weight": True},
|
||||
},
|
||||
)
|
||||
initial = service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
with patch(
|
||||
"conversation_service.modify_calculation",
|
||||
wraps=modify_calculation,
|
||||
) as deterministic_modifier:
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Ändere bitte die Rollenlänge auf 80m und berechne das Rollengewicht.",
|
||||
)
|
||||
|
||||
deterministic_modifier.assert_called_once()
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(response["result"]["request"]["roll_length_m"], 80.0)
|
||||
self.assertTrue(response["result"]["request"]["include_roll_weight"])
|
||||
self.assertIsNotNone(response["result"]["calculation"]["roll_weight_kg"])
|
||||
self.assertNotEqual(response["pdf"]["report_id"], initial["pdf"]["report_id"])
|
||||
|
||||
def test_multi_field_length_and_width_follow_up(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_length_m": 65.0, "width_m": 5.0},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
response = service.handle_message(conversation_id, "Nimm 65 m Länge und 5,0 m Breite.")
|
||||
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(response["result"]["request"]["roll_length_m"], 65.0)
|
||||
self.assertEqual(response["result"]["request"]["width_m"], 5.0)
|
||||
|
||||
def test_multi_field_core_preset_and_length_follow_up(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {
|
||||
"core_type": "194-mm-Stahlkern",
|
||||
"roll_length_m": 80.0,
|
||||
},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Nimm den 194-mm-Stahlkern und 80 m Länge.",
|
||||
)
|
||||
|
||||
self.assertEqual(response["status"], "success")
|
||||
request = response["result"]["request"]
|
||||
self.assertEqual(request["roll_length_m"], 80.0)
|
||||
self.assertEqual(request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(request["core_diameter_mm"], 194.0)
|
||||
|
||||
def test_incomplete_new_weight_output_inherits_current_calculation(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
roll_length_m=80.0,
|
||||
core_type="194mm Stahl",
|
||||
core_diameter_mm=194.0,
|
||||
),
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint=None,
|
||||
roll_length_m=None,
|
||||
width_m=5.0,
|
||||
core_type=None,
|
||||
core_diameter_mm=None,
|
||||
include_roll_weight=True,
|
||||
),
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
with patch(
|
||||
"conversation_service.modify_calculation",
|
||||
wraps=modify_calculation,
|
||||
) as deterministic_modifier:
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Wieviel wiegt die Rolle bei 5,0 m Breite?",
|
||||
)
|
||||
|
||||
changes = deterministic_modifier.call_args.args[1]
|
||||
self.assertEqual(
|
||||
changes,
|
||||
{"width_m": 5.0, "include_roll_weight": True},
|
||||
)
|
||||
request = response["result"]["state"]["request"]
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(request["article_number"], "180205")
|
||||
self.assertEqual(request["roll_length_m"], 80.0)
|
||||
self.assertEqual(request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(request["core_diameter_mm"], 194.0)
|
||||
self.assertEqual(request["width_m"], 5.0)
|
||||
self.assertTrue(request["include_roll_weight"])
|
||||
self.assertIsNotNone(response["result"]["calculation"]["roll_weight_kg"])
|
||||
self.assertIn("pdf", response)
|
||||
current_context = client.calls[1]["current_state"]
|
||||
self.assertTrue(current_context["has_successful_calculation"])
|
||||
self.assertEqual(current_context["article_number"], "180205")
|
||||
self.assertEqual(
|
||||
current_context["resolved_article_name"],
|
||||
"Bfix NSP 4900, 5,00 x 40 m",
|
||||
)
|
||||
self.assertEqual(current_context["roll_length_m"], 80.0)
|
||||
self.assertNotIn("calculation", current_context)
|
||||
|
||||
def test_incomplete_new_length_output_inherits_article_and_core(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
roll_length_m=65.0,
|
||||
core_type="194mm Stahl",
|
||||
core_diameter_mm=194.0,
|
||||
),
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint=None,
|
||||
roll_length_m=80.0,
|
||||
width_m=None,
|
||||
core_type=None,
|
||||
core_diameter_mm=None,
|
||||
),
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
response = service.handle_message(conversation_id, "Wie sieht es bei 80 m aus?")
|
||||
|
||||
request = response["result"]["state"]["request"]
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(request["article_number"], "180205")
|
||||
self.assertEqual(request["roll_length_m"], 80.0)
|
||||
self.assertEqual(request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(request["core_diameter_mm"], 194.0)
|
||||
|
||||
def test_explicit_new_article_does_not_inherit_current_state(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
roll_length_m=80.0,
|
||||
core_type="194mm Stahl",
|
||||
core_diameter_mm=194.0,
|
||||
),
|
||||
new_interpretation(
|
||||
article_number="146900",
|
||||
article_name_hint=None,
|
||||
roll_length_m=50.0,
|
||||
width_m=None,
|
||||
core_type="150mm PVC",
|
||||
core_diameter_mm=150.0,
|
||||
),
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
with patch(
|
||||
"conversation_service.calculate_roll",
|
||||
wraps=calculate_roll,
|
||||
) as deterministic_calculator:
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Neue Berechnung für Artikel 146900.",
|
||||
)
|
||||
|
||||
deterministic_calculator.assert_called_once()
|
||||
request = response["result"]["state"]["request"]
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(request["article_number"], "146900")
|
||||
self.assertEqual(request["roll_length_m"], 50.0)
|
||||
self.assertEqual(request["core_diameter_mm"], 150.0)
|
||||
|
||||
def test_different_article_in_modify_output_starts_new_calculation(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"article_number": "146900"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
with patch(
|
||||
"conversation_service.calculate_roll",
|
||||
wraps=calculate_roll,
|
||||
) as deterministic_calculator:
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Neue Berechnung für Artikel 146900.",
|
||||
)
|
||||
|
||||
deterministic_calculator.assert_called_once()
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(response["result"]["request"]["article_number"], "146900")
|
||||
self.assertIsNone(response["result"]["request"]["roll_length_m"])
|
||||
self.assertIsNone(response["result"]["request"]["core_diameter_mm"])
|
||||
|
||||
def test_explicit_new_calculation_phrase_does_not_trigger_inheritance(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint=None,
|
||||
roll_length_m=80.0,
|
||||
width_m=None,
|
||||
core_type=None,
|
||||
core_diameter_mm=None,
|
||||
),
|
||||
)
|
||||
service.handle_message(conversation_id, "Erste Berechnung")
|
||||
|
||||
with patch("conversation_service.modify_calculation") as modifier:
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Neue Berechnung mit 80 m.",
|
||||
)
|
||||
|
||||
modifier.assert_not_called()
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertIsNone(response["result"]["request"]["article_number"])
|
||||
self.assertIn("thickness_mm", response["result"]["missing"])
|
||||
|
||||
def test_no_current_state_does_not_inherit_incomplete_request(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint=None,
|
||||
roll_length_m=80.0,
|
||||
width_m=None,
|
||||
core_type=None,
|
||||
core_diameter_mm=None,
|
||||
)
|
||||
)
|
||||
|
||||
with patch("conversation_service.modify_calculation") as modifier:
|
||||
response = service.handle_message(conversation_id, "Rechne mit 80 m.")
|
||||
|
||||
modifier.assert_not_called()
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertIn("thickness_mm", response["result"]["missing"])
|
||||
self.assertIn("core_diameter_mm", response["result"]["missing"])
|
||||
|
||||
def test_failed_followup_nlu_does_not_mutate_valid_state(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
NLUValidationError("invalid follow-up"),
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
before = service.conversation_state(conversation_id)
|
||||
|
||||
response = service.handle_message(conversation_id, "Ungültige Änderung")
|
||||
after = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(response["status"], "nlu_error")
|
||||
self.assertEqual(after.calculation_state, before.calculation_state)
|
||||
self.assertEqual(after.last_result, before.last_result)
|
||||
|
||||
def test_ambiguous_steel_core_uses_deterministic_clarification(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": "Stahlkern"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
valid_state = service.conversation_state(conversation_id).calculation_state
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Bitte ändere den Kern auf einen Stahlkern.",
|
||||
)
|
||||
record = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(
|
||||
response["message"],
|
||||
"Es sind mehrere Stahlkerne hinterlegt. Welchen Kerndurchmesser "
|
||||
"soll ich verwenden: 133 mm oder 194 mm?",
|
||||
)
|
||||
self.assertEqual(
|
||||
response["pending_clarification"]["allowed_values"],
|
||||
[133.0, 194.0],
|
||||
)
|
||||
self.assertEqual(
|
||||
response["pending_clarification"]["core_family"],
|
||||
"steel",
|
||||
)
|
||||
self.assertEqual(record.calculation_state, valid_state)
|
||||
self.assertNotIn(
|
||||
"core_diameter_mm",
|
||||
response["interpretation"]["changes"],
|
||||
)
|
||||
|
||||
def test_ambiguous_pvc_core_lists_all_canonical_diameters(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": "plastic core"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
response = service.handle_message(conversation_id, "Nimm einen PVC-Kern.")
|
||||
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(
|
||||
response["message"],
|
||||
"Es sind mehrere PVC-Kerne hinterlegt. Welchen Kerndurchmesser "
|
||||
"soll ich verwenden: 150 mm oder 168 mm?",
|
||||
)
|
||||
self.assertEqual(
|
||||
response["pending_clarification"]["allowed_values"],
|
||||
[150.0, 168.0],
|
||||
)
|
||||
|
||||
def test_valid_ambiguous_core_candidate_resolves_atomically(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": "Stahlkern"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
service.handle_message(conversation_id, "Nimm einen Stahlkern.")
|
||||
|
||||
with patch(
|
||||
"conversation_service.modify_calculation",
|
||||
wraps=modify_calculation,
|
||||
) as deterministic_modifier:
|
||||
response = service.handle_message(conversation_id, "194 mm")
|
||||
|
||||
record = service.conversation_state(conversation_id)
|
||||
deterministic_modifier.assert_called_once()
|
||||
self.assertEqual(len(client.calls), 2)
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertIsNone(record.pending_clarification)
|
||||
self.assertEqual(
|
||||
record.calculation_state["request"]["core_type"],
|
||||
"194mm Stahl",
|
||||
)
|
||||
self.assertEqual(
|
||||
record.calculation_state["request"]["core_diameter_mm"],
|
||||
194.0,
|
||||
)
|
||||
self.assertEqual(
|
||||
response["result"]["effective_inputs"]["core_diameter_mm"]["value"],
|
||||
194.0,
|
||||
)
|
||||
report_request = service.report_request(response["pdf"]["report_id"])
|
||||
self.assertEqual(report_request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(report_request["core_diameter_mm"], 194.0)
|
||||
|
||||
def test_ambiguous_multi_change_preserves_patch_until_core_selection(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_length_m": 80.0, "core_type": "Stahlkern"},
|
||||
},
|
||||
)
|
||||
initial = service.handle_message(conversation_id, "Neue Berechnung")
|
||||
before = service.conversation_state(conversation_id)
|
||||
pending_response = service.handle_message(
|
||||
conversation_id,
|
||||
"Nimm 80 m und einen Stahlkern.",
|
||||
)
|
||||
pending = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(pending_response["status"], "needs_clarification")
|
||||
self.assertEqual(pending.calculation_state, before.calculation_state)
|
||||
self.assertEqual(
|
||||
pending.pending_clarification.requested_changes,
|
||||
{"roll_length_m": 80.0, "core_type": "Stahlkern"},
|
||||
)
|
||||
self.assertEqual(
|
||||
pending_response["pending_clarification"]["requested_changes"],
|
||||
{"roll_length_m": 80.0, "core_type": "Stahlkern"},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"conversation_service.modify_calculation",
|
||||
wraps=modify_calculation,
|
||||
) as deterministic_modifier:
|
||||
response = service.handle_message(conversation_id, "194 mm")
|
||||
|
||||
deterministic_modifier.assert_called_once()
|
||||
self.assertEqual(response["status"], "success")
|
||||
request = response["result"]["request"]
|
||||
self.assertEqual(request["roll_length_m"], 80.0)
|
||||
self.assertEqual(request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(request["core_diameter_mm"], 194.0)
|
||||
self.assertNotEqual(response["pdf"]["report_id"], initial["pdf"]["report_id"])
|
||||
|
||||
def test_invalid_ambiguous_core_candidate_keeps_valid_state_and_pending(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": "Stahlkern"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
valid_record = service.conversation_state(conversation_id)
|
||||
service.handle_message(conversation_id, "Nimm einen Stahlkern.")
|
||||
|
||||
with patch("conversation_service.modify_calculation") as modifier:
|
||||
response = service.handle_message(conversation_id, "200 mm")
|
||||
|
||||
record = service.conversation_state(conversation_id)
|
||||
modifier.assert_not_called()
|
||||
self.assertEqual(len(client.calls), 2)
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(
|
||||
response["message"],
|
||||
"200 mm ist für einen Stahlkern nicht hinterlegt. "
|
||||
"Verfügbar sind 133 mm und 194 mm.",
|
||||
)
|
||||
self.assertNotIn("pdf", response)
|
||||
self.assertEqual(record.calculation_state, valid_record.calculation_state)
|
||||
self.assertEqual(record.last_result, valid_record.last_result)
|
||||
self.assertEqual(record.pending_fields, ("core_diameter_mm",))
|
||||
self.assertEqual(
|
||||
record.pending_clarification.allowed_values,
|
||||
(133.0, 194.0),
|
||||
)
|
||||
|
||||
def test_explicit_steel_preset_alias_recalculates_and_updates_pdf_request(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": "194 mm steel core"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
with patch(
|
||||
"conversation_service.modify_calculation",
|
||||
wraps=modify_calculation,
|
||||
) as deterministic_modifier:
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Bitte ändere den Kern auf 194 mm steel core.",
|
||||
)
|
||||
|
||||
deterministic_modifier.assert_called_once()
|
||||
self.assertEqual(response["status"], "success")
|
||||
request = response["result"]["state"]["request"]
|
||||
self.assertEqual(request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(request["core_diameter_mm"], 194.0)
|
||||
self.assertEqual(
|
||||
response["result"]["effective_inputs"]["core_type"]["value"],
|
||||
"194mm Stahl",
|
||||
)
|
||||
report_request = service.report_request(response["pdf"]["report_id"])
|
||||
self.assertEqual(report_request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(report_request["core_diameter_mm"], 194.0)
|
||||
|
||||
def test_missing_width_is_filled_by_field_locked_clarification(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number="6001316",
|
||||
article_name_hint=None,
|
||||
include_roll_weight=True,
|
||||
),
|
||||
)
|
||||
clarification = service.handle_message(conversation_id, "Mit Gewicht")
|
||||
|
||||
self.assertEqual(clarification["status"], "needs_clarification")
|
||||
self.assertIn("Welche Breite", clarification["message"])
|
||||
self.assertIsNone(clarification["interpretation"]["width_m"])
|
||||
|
||||
response = service.handle_message(conversation_id, "4,9 m")
|
||||
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(
|
||||
response["interpretation"],
|
||||
{"status": "parsed", "field": "width_m", "value": 4.9},
|
||||
)
|
||||
self.assertEqual(len(client.calls), 1)
|
||||
|
||||
def test_pending_core_diameter_accepts_mm_reply_without_calling_ollama(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(core_type=None)
|
||||
)
|
||||
initial = service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
response = service.handle_message(conversation_id, "150 mm")
|
||||
|
||||
self.assertEqual(initial["status"], "needs_clarification")
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(
|
||||
response["result"]["state"]["request"]["core_diameter_mm"],
|
||||
150.0,
|
||||
)
|
||||
self.assertEqual(len(client.calls), 1)
|
||||
|
||||
def test_pending_core_diameter_accepts_unitless_reply(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(core_type=None)
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
response = service.handle_message(conversation_id, "150")
|
||||
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(
|
||||
response["result"]["state"]["request"]["core_diameter_mm"],
|
||||
150.0,
|
||||
)
|
||||
|
||||
def test_incompatible_core_reply_keeps_state_result_and_pending_field(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(core_type=None)
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
before = service.conversation_state(conversation_id)
|
||||
|
||||
with patch("conversation_service.modify_calculation") as modifier:
|
||||
response = service.handle_message(conversation_id, "4,90 m")
|
||||
after = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(
|
||||
response["clarification"],
|
||||
{"status": "incompatible", "field": "core_diameter_mm"},
|
||||
)
|
||||
self.assertIn("Millimetern", response["message"])
|
||||
self.assertEqual(after.calculation_state, before.calculation_state)
|
||||
self.assertEqual(after.last_result, before.last_result)
|
||||
self.assertEqual(after.pending_fields, ("core_diameter_mm",))
|
||||
self.assertEqual(len(client.calls), 1)
|
||||
modifier.assert_not_called()
|
||||
|
||||
def test_pending_roll_length_accepts_meter_reply(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(roll_length_m=None)
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
response = service.handle_message(conversation_id, "80 m")
|
||||
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(
|
||||
response["result"]["calculation"]["effective_roll_length_m"],
|
||||
80.0,
|
||||
)
|
||||
|
||||
def test_article_conflict_remains_deterministic(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(article_name_hint="Stex R 1801")
|
||||
)
|
||||
|
||||
response = service.handle_message(conversation_id, "Konflikt")
|
||||
|
||||
self.assertEqual(response["status"], "article_conflict")
|
||||
self.assertIn("passen nicht zusammen", response["message"])
|
||||
|
||||
def test_ambiguous_article_lists_repository_candidates_with_dimensions(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint="Stex R 1801",
|
||||
)
|
||||
)
|
||||
|
||||
response = service.handle_message(conversation_id, "Berechnen")
|
||||
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(response["field"], "article")
|
||||
self.assertEqual(response["reason"], "ambiguous_article")
|
||||
self.assertEqual(
|
||||
response["candidates"],
|
||||
response["pending_clarification"]["candidates"],
|
||||
)
|
||||
self.assertEqual(response["pending_clarification"], {
|
||||
"field": "article",
|
||||
"reason": "ambiguous_article",
|
||||
"candidates": [
|
||||
{
|
||||
"article_number": "146900",
|
||||
"name": "Stex R 1801, 5,80 x 50 m",
|
||||
},
|
||||
{
|
||||
"article_number": "146910",
|
||||
"name": "Stex R 1801, 6,00 x 50 m",
|
||||
},
|
||||
],
|
||||
})
|
||||
self.assertEqual(
|
||||
response["message"],
|
||||
"Ich habe mehrere passende Artikel gefunden. Welchen meinst Du:\n"
|
||||
"146900 – Stex R 1801, 5,80 x 50 m\n"
|
||||
"oder\n"
|
||||
"146910 – Stex R 1801, 6,00 x 50 m?",
|
||||
)
|
||||
|
||||
def test_product_alias_clarification_uses_repository_candidates_only(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint="Secutex R501",
|
||||
)
|
||||
)
|
||||
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Welchen Durchmesser hat eine Secutex R501 mit 100m Länge?",
|
||||
)
|
||||
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
candidates = response["pending_clarification"]["candidates"]
|
||||
self.assertIn(
|
||||
{
|
||||
"article_number": "214507",
|
||||
"name": "Stex R 501 (Betonvlies), 5,20 x 100 m",
|
||||
},
|
||||
candidates,
|
||||
)
|
||||
self.assertTrue(
|
||||
all(candidate["article_number"].isdigit() for candidate in candidates)
|
||||
)
|
||||
|
||||
def test_article_candidate_number_followup_skips_ollama(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint="Stex R 1801",
|
||||
)
|
||||
)
|
||||
service.handle_message(conversation_id, "Berechnen")
|
||||
|
||||
response = service.handle_message(conversation_id, "146900")
|
||||
|
||||
self.assertEqual(len(client.calls), 1)
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(
|
||||
response["interpretation"],
|
||||
{"status": "parsed", "field": "article", "value": "146900"},
|
||||
)
|
||||
self.assertEqual(response["result"]["resolved_article"]["number"], "146900")
|
||||
self.assertIsNone(
|
||||
service.conversation_state(conversation_id).pending_clarification
|
||||
)
|
||||
|
||||
def test_invalid_article_candidate_keeps_pending_state_without_ollama(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint="Stex R 1801",
|
||||
)
|
||||
)
|
||||
service.handle_message(conversation_id, "Berechnen")
|
||||
before = service.conversation_state(conversation_id)
|
||||
|
||||
response = service.handle_message(conversation_id, "999999")
|
||||
after = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(len(client.calls), 1)
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(
|
||||
response["interpretation"],
|
||||
{"status": "incompatible", "field": "article"},
|
||||
)
|
||||
self.assertEqual(after, before)
|
||||
|
||||
def test_unresolved_article_ambiguity_does_not_mutate_valid_state(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"article_name_hint": "Stex R 1801"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Berechnen")
|
||||
before = service.conversation_state(conversation_id)
|
||||
|
||||
response = service.handle_message(conversation_id, "Nimm Stex R 1801")
|
||||
after = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(after.calculation_state, before.calculation_state)
|
||||
self.assertEqual(after.last_result, before.last_result)
|
||||
self.assertEqual(after.pending_fields, ("article",))
|
||||
|
||||
def test_unknown_article_name_has_deterministic_not_found_message(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint="Nicht vorhandenes Produkt XYZ",
|
||||
)
|
||||
)
|
||||
|
||||
response = service.handle_message(conversation_id, "Berechnen")
|
||||
|
||||
self.assertEqual(response["status"], "article_not_found")
|
||||
self.assertEqual(
|
||||
response["message"],
|
||||
"Der angegebene Artikel wurde nicht gefunden.",
|
||||
)
|
||||
|
||||
def test_numeric_message_values_come_from_calculation_result(self):
|
||||
service, _, conversation_id = self.create_service(new_interpretation())
|
||||
response = service.handle_message(conversation_id, "Berechnen")
|
||||
calculation = response["result"]["calculation"]
|
||||
|
||||
self.assertIn(
|
||||
f"Minimum: {calculation['minimum_diameter_mm']:.1f} mm",
|
||||
response["message"],
|
||||
)
|
||||
self.assertIn(
|
||||
f"Mittelwert: {calculation['average_diameter_mm']:.1f} mm",
|
||||
response["message"],
|
||||
)
|
||||
self.assertIn(
|
||||
f"Maximum: {calculation['maximum_diameter_mm']:.1f} mm",
|
||||
response["message"],
|
||||
)
|
||||
|
||||
def test_ollama_failure_is_controlled_and_does_not_mutate_state(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
OllamaUnavailableError("offline")
|
||||
)
|
||||
|
||||
response = service.handle_message(conversation_id, "Berechnen")
|
||||
record = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(response["status"], "nlu_error")
|
||||
self.assertEqual(response["error"], "OllamaUnavailableError")
|
||||
self.assertIsNone(record.calculation_state)
|
||||
self.assertIsNone(record.last_result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,67 @@
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import app as rollcalc_app
|
||||
from core_presets import CORE_PRESETS, matching_core_presets
|
||||
|
||||
|
||||
class CorePresetTests(unittest.TestCase):
|
||||
def test_rendered_web_ui_preset_list_is_unchanged(self):
|
||||
expected = [
|
||||
("133", "steel", "133mm Stahl"),
|
||||
("150", "pvc", "150mm PVC"),
|
||||
("168", "pvc", "168mm PVC"),
|
||||
("194", "steel", "194mm Stahl"),
|
||||
]
|
||||
credentials = base64.b64encode(b"mtazl:rollcalc").decode("ascii")
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
log_file = os.path.join(temp_dir, "access.json")
|
||||
with patch.object(rollcalc_app, "LOG_FILE", log_file):
|
||||
response = rollcalc_app.app.test_client().get(
|
||||
"/",
|
||||
headers={"Authorization": f"Basic {credentials}"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rendered = re.findall(
|
||||
r'class="core-preset-btn" data-core="([^"]+)" '
|
||||
r'data-core-material="([^"]+)">([^<]+)</button>',
|
||||
response.get_data(as_text=True),
|
||||
)
|
||||
self.assertEqual(rendered, expected)
|
||||
self.assertEqual(
|
||||
[
|
||||
(preset.ui_diameter, preset.material, preset.label)
|
||||
for preset in CORE_PRESETS
|
||||
],
|
||||
expected,
|
||||
)
|
||||
|
||||
def test_explicit_finite_aliases_resolve_one_canonical_preset(self):
|
||||
german = matching_core_presets("194 mm Stahlkern")
|
||||
english = matching_core_presets("194 mm steel core")
|
||||
|
||||
self.assertEqual(german, english)
|
||||
self.assertEqual(len(german), 1)
|
||||
self.assertEqual(german[0].key, "steel_194")
|
||||
|
||||
def test_generic_existing_families_are_genuinely_ambiguous(self):
|
||||
self.assertEqual(
|
||||
[preset.key for preset in matching_core_presets("Stahlkern")],
|
||||
["steel_133", "steel_194"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[preset.key for preset in matching_core_presets("plastic core")],
|
||||
["pvc_150", "pvc_168"],
|
||||
)
|
||||
|
||||
def test_unknown_core_label_does_not_match(self):
|
||||
self.assertEqual(matching_core_presets("cardboard core"), ())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,316 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
from ollama_nlu import (
|
||||
NLUValidationError,
|
||||
OllamaConfig,
|
||||
OllamaNLUClient,
|
||||
OllamaResponseError,
|
||||
OllamaTimeoutError,
|
||||
OllamaUnavailableError,
|
||||
parse_clarification_reply,
|
||||
parse_nlu_json,
|
||||
validate_nlu_payload,
|
||||
)
|
||||
|
||||
|
||||
def new_calculation_payload(**changes):
|
||||
payload = {
|
||||
"intent": "new_calculation",
|
||||
"article_number": "180205",
|
||||
"article_name_hint": "Bentofix NSP 4900",
|
||||
"roll_length_m": 65.0,
|
||||
"width_m": None,
|
||||
"core_type": None,
|
||||
"core_diameter_mm": None,
|
||||
"include_roll_weight": False,
|
||||
}
|
||||
payload.update(changes)
|
||||
return payload
|
||||
|
||||
|
||||
class FakeResponse(io.BytesIO):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self.close()
|
||||
|
||||
|
||||
class RecordingOpener:
|
||||
def __init__(self, model_payload):
|
||||
self.model_payload = model_payload
|
||||
self.request_payload = None
|
||||
self.timeout = None
|
||||
|
||||
def __call__(self, request, *, timeout):
|
||||
self.request_payload = json.loads(request.data.decode("utf-8"))
|
||||
self.timeout = timeout
|
||||
response = {
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": json.dumps(self.model_payload),
|
||||
},
|
||||
"done": True,
|
||||
}
|
||||
return FakeResponse(json.dumps(response).encode("utf-8"))
|
||||
|
||||
|
||||
class NLUValidationTests(unittest.TestCase):
|
||||
def test_core_diameter_clarification_accepts_mm_and_unitless_values(self):
|
||||
with_unit = parse_clarification_reply("150 mm", "core_diameter_mm")
|
||||
unitless = parse_clarification_reply("150", "core_diameter_mm")
|
||||
|
||||
self.assertEqual(
|
||||
with_unit.to_dict(),
|
||||
{"status": "parsed", "field": "core_diameter_mm", "value": 150.0},
|
||||
)
|
||||
self.assertEqual(unitless.to_dict(), with_unit.to_dict())
|
||||
|
||||
def test_core_diameter_clarification_rejects_meter_unit(self):
|
||||
interpretation = parse_clarification_reply("4,90 m", "core_diameter_mm")
|
||||
|
||||
self.assertEqual(
|
||||
interpretation.to_dict(),
|
||||
{"status": "incompatible", "field": "core_diameter_mm"},
|
||||
)
|
||||
|
||||
def test_length_and_width_clarifications_use_expected_meter_field(self):
|
||||
length = parse_clarification_reply("80 m", "roll_length_m")
|
||||
width = parse_clarification_reply("4,90 m", "width_m")
|
||||
|
||||
self.assertEqual(length.value, 80.0)
|
||||
self.assertEqual(length.field, "roll_length_m")
|
||||
self.assertEqual(width.value, 4.9)
|
||||
self.assertEqual(width.field, "width_m")
|
||||
|
||||
def test_empty_optional_article_name_is_normalized_to_none(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
new_calculation_payload(article_name_hint="")
|
||||
)
|
||||
|
||||
self.assertIsNone(interpretation.article_name_hint)
|
||||
|
||||
def test_whitespace_optional_article_name_is_normalized_to_none(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
new_calculation_payload(article_name_hint=" ")
|
||||
)
|
||||
|
||||
self.assertIsNone(interpretation.article_name_hint)
|
||||
|
||||
def test_null_optional_article_name_remains_none(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
new_calculation_payload(article_name_hint=None)
|
||||
)
|
||||
|
||||
self.assertIsNone(interpretation.article_name_hint)
|
||||
|
||||
def test_valid_optional_article_name_is_preserved(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
new_calculation_payload(article_name_hint="Bentofix NSP 4900")
|
||||
)
|
||||
|
||||
self.assertEqual(interpretation.article_name_hint, "Bentofix NSP 4900")
|
||||
|
||||
def test_empty_text_is_still_invalid_for_a_selected_modification(self):
|
||||
with self.assertRaises(NLUValidationError):
|
||||
validate_nlu_payload(
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": " "},
|
||||
}
|
||||
)
|
||||
|
||||
def test_new_calculation_extracts_requested_german_fields(self):
|
||||
interpretation = validate_nlu_payload(new_calculation_payload())
|
||||
|
||||
self.assertEqual(interpretation.intent, "new_calculation")
|
||||
self.assertEqual(interpretation.article_number, "180205")
|
||||
self.assertEqual(interpretation.article_name_hint, "Bentofix NSP 4900")
|
||||
self.assertEqual(interpretation.roll_length_m, 65.0)
|
||||
self.assertIsNone(interpretation.width_m)
|
||||
self.assertIsNone(interpretation.core_diameter_mm)
|
||||
|
||||
def test_length_modification_contains_only_the_requested_change(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_length_m": 80.0},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
interpretation.to_dict(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_length_m": 80.0},
|
||||
},
|
||||
)
|
||||
|
||||
def test_steel_core_modification_never_contains_a_guessed_diameter(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": "steel"},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(interpretation.changes, {"core_type": "steel"})
|
||||
self.assertNotIn("core_diameter_mm", interpretation.changes)
|
||||
|
||||
def test_modification_accepts_multiple_explicit_allowed_changes(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {
|
||||
"roll_length_m": 80.0,
|
||||
"width_m": 4.9,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
interpretation.changes,
|
||||
{"roll_length_m": 80.0, "width_m": 4.9},
|
||||
)
|
||||
|
||||
def test_forbidden_calculated_fields_are_rejected_at_every_level(self):
|
||||
with self.assertRaises(NLUValidationError):
|
||||
validate_nlu_payload(
|
||||
new_calculation_payload(average_diameter_mm=999.0)
|
||||
)
|
||||
with self.assertRaises(NLUValidationError):
|
||||
validate_nlu_payload(
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_weight_kg": 1.0},
|
||||
}
|
||||
)
|
||||
|
||||
def test_invalid_json_is_handled_without_fallback_parsing(self):
|
||||
with self.assertRaises(NLUValidationError):
|
||||
parse_nlu_json("```json\n{}\n```")
|
||||
|
||||
|
||||
class OllamaClientTests(unittest.TestCase):
|
||||
def test_model_uses_available_default_and_environment_override(self):
|
||||
self.assertEqual(OllamaConfig().model, "qwen3.5:35B-A3B")
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"ROLLCALC_OLLAMA_MODEL": "custom-qwen:model"},
|
||||
):
|
||||
self.assertEqual(
|
||||
OllamaConfig.from_env().model,
|
||||
"custom-qwen:model",
|
||||
)
|
||||
|
||||
def test_german_request_uses_constrained_non_thinking_chat_request(self):
|
||||
opener = RecordingOpener(new_calculation_payload())
|
||||
config = OllamaConfig(
|
||||
base_url="http://ollama.test:11434",
|
||||
model="custom-qwen:model",
|
||||
timeout_seconds=12,
|
||||
)
|
||||
client = OllamaNLUClient(config, opener=opener)
|
||||
|
||||
response = client.interpret(
|
||||
"Welchen Durchmesser hat Bentofix NSP 4900, "
|
||||
"Artikelnummer 180205 bei 65 m Länge?"
|
||||
)
|
||||
|
||||
self.assertEqual(response.interpretation.article_number, "180205")
|
||||
self.assertEqual(response.interpretation.roll_length_m, 65.0)
|
||||
self.assertEqual(
|
||||
opener.request_payload["messages"][1]["content"],
|
||||
"Welchen Durchmesser hat Bentofix NSP 4900, "
|
||||
"Artikelnummer 180205 bei 65 m Länge?",
|
||||
)
|
||||
self.assertEqual(opener.request_payload["model"], "custom-qwen:model")
|
||||
self.assertEqual(opener.request_payload["options"]["temperature"], 0.0)
|
||||
self.assertFalse(opener.request_payload["think"])
|
||||
self.assertFalse(opener.request_payload["stream"])
|
||||
self.assertIsInstance(opener.request_payload["format"], dict)
|
||||
self.assertIn(
|
||||
"never return an empty or whitespace-only string",
|
||||
opener.request_payload["messages"][0]["content"],
|
||||
)
|
||||
self.assertEqual(opener.timeout, 12)
|
||||
|
||||
def test_generated_ollama_schema_has_no_regex_patterns(self):
|
||||
opener = RecordingOpener(new_calculation_payload())
|
||||
|
||||
OllamaNLUClient(opener=opener).interpret("Artikel 180205 mit 65 Metern")
|
||||
|
||||
encoded_schema = json.dumps(opener.request_payload["format"])
|
||||
self.assertNotIn('"pattern"', encoded_schema)
|
||||
|
||||
def test_current_input_state_is_provided_without_calculated_results(self):
|
||||
opener = RecordingOpener(
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"width_m": 5.0},
|
||||
}
|
||||
)
|
||||
current_state = {
|
||||
"has_successful_calculation": True,
|
||||
"article_number": "180205",
|
||||
"roll_length_m": 80.0,
|
||||
"width_m": None,
|
||||
"core_type": "194mm Stahl",
|
||||
"core_diameter_mm": 194.0,
|
||||
"include_roll_weight": False,
|
||||
}
|
||||
|
||||
OllamaNLUClient(opener=opener).interpret(
|
||||
"Wieviel wiegt die Rolle bei 5 m Breite?",
|
||||
has_state=True,
|
||||
current_state=current_state,
|
||||
)
|
||||
|
||||
system_prompt = opener.request_payload["messages"][0]["content"]
|
||||
self.assertIn('"current_calculation":{', system_prompt)
|
||||
self.assertIn('"article_number":"180205"', system_prompt)
|
||||
self.assertIn('"roll_length_m":80.0', system_prompt)
|
||||
self.assertIn("die Rolle, dieselbe Rolle", system_prompt)
|
||||
self.assertNotIn("average_diameter_mm", system_prompt)
|
||||
self.assertNotIn("roll_weight_kg", system_prompt)
|
||||
|
||||
def test_timeout_and_unavailable_service_are_controlled(self):
|
||||
def timeout_opener(request, *, timeout):
|
||||
raise socket.timeout()
|
||||
|
||||
def unavailable_opener(request, *, timeout):
|
||||
raise URLError(ConnectionRefusedError())
|
||||
|
||||
with self.assertRaises(OllamaTimeoutError):
|
||||
OllamaNLUClient(opener=timeout_opener).interpret("Test")
|
||||
with self.assertRaises(OllamaUnavailableError):
|
||||
OllamaNLUClient(opener=unavailable_opener).interpret("Test")
|
||||
|
||||
def test_http_error_includes_status_and_ollama_response_detail(self):
|
||||
def bad_request_opener(request, *, timeout):
|
||||
raise HTTPError(
|
||||
request.full_url,
|
||||
400,
|
||||
"Bad Request",
|
||||
hdrs=None,
|
||||
fp=io.BytesIO(
|
||||
b'{"error":"some Ollama explanation"}'
|
||||
),
|
||||
)
|
||||
|
||||
with self.assertRaises(OllamaResponseError) as raised:
|
||||
OllamaNLUClient(opener=bad_request_opener).interpret("Test")
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 400)
|
||||
self.assertIn("HTTP 400", str(raised.exception))
|
||||
self.assertIn("some Ollama explanation", str(raised.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from ollama_nlu import OllamaNLUClient
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.getenv("ROLLCALC_RUN_OLLAMA_SMOKE") == "1",
|
||||
"set ROLLCALC_RUN_OLLAMA_SMOKE=1 to use the local Ollama model",
|
||||
)
|
||||
class RealOllamaSmokeTests(unittest.TestCase):
|
||||
def test_representative_german_request(self):
|
||||
response = OllamaNLUClient().interpret(
|
||||
"Welchen Durchmesser hat Bentofix NSP 4900, "
|
||||
"Artikelnummer 180205 bei 65 m Länge?"
|
||||
)
|
||||
|
||||
interpretation = response.interpretation
|
||||
self.assertEqual(interpretation.intent, "new_calculation")
|
||||
self.assertEqual(interpretation.article_number, "180205")
|
||||
self.assertEqual(interpretation.article_name_hint, "Bentofix NSP 4900")
|
||||
self.assertEqual(interpretation.roll_length_m, 65.0)
|
||||
self.assertIsNone(interpretation.width_m)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,289 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class _BaseModel:
|
||||
def __init__(self, **values):
|
||||
for name in self.__class__.__annotations__:
|
||||
setattr(self, name, values.get(name, getattr(self.__class__, name)))
|
||||
|
||||
|
||||
class _SecretStr:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def get_secret_value(self):
|
||||
return self._value
|
||||
|
||||
|
||||
def _field(*, default, **_kwargs):
|
||||
return default
|
||||
|
||||
|
||||
class _RequestError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _TimeoutException(_RequestError):
|
||||
pass
|
||||
|
||||
|
||||
class _BasicAuth:
|
||||
def __init__(self, username, password):
|
||||
self.username = username
|
||||
self.password = password
|
||||
|
||||
|
||||
def _load_pipe_module():
|
||||
pydantic = types.ModuleType("pydantic")
|
||||
pydantic.BaseModel = _BaseModel
|
||||
pydantic.Field = _field
|
||||
pydantic.SecretStr = _SecretStr
|
||||
httpx = types.ModuleType("httpx")
|
||||
httpx.AsyncClient = object
|
||||
httpx.BasicAuth = _BasicAuth
|
||||
httpx.RequestError = _RequestError
|
||||
httpx.TimeoutException = _TimeoutException
|
||||
module_path = (
|
||||
Path(__file__).parents[1]
|
||||
/ "integrations"
|
||||
/ "openwebui"
|
||||
/ "rollcalc_pipe.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("rollcalc_pipe_test", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, {"pydantic": pydantic, "httpx": httpx}):
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
PIPE_MODULE = _load_pipe_module()
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code, payload):
|
||||
self.status_code = status_code
|
||||
self.payload = payload
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class FakeClientFactory:
|
||||
def __init__(self, responses=None, error=None):
|
||||
self.responses = list(responses or [])
|
||||
self.error = error
|
||||
self.calls = []
|
||||
self.options = []
|
||||
|
||||
def __call__(self, **options):
|
||||
self.options.append(options)
|
||||
return self
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def post(self, url, **options):
|
||||
self.calls.append((url, options))
|
||||
if self.error:
|
||||
raise self.error
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
class OpenWebUIPipeTests(unittest.IsolatedAsyncioTestCase):
|
||||
def configured_pipe(self, factory):
|
||||
pipe = PIPE_MODULE.Pipe(client_factory=factory)
|
||||
pipe.valves.ROLLCALC_API_BASE_URL = "http://rollcalc.internal:5000"
|
||||
pipe.valves.ROLLCALC_PUBLIC_BASE_URL = "https://rollcalc.example.test"
|
||||
pipe.valves.ROLLCALC_USERNAME = "service-user"
|
||||
pipe.valves.ROLLCALC_PASSWORD = _SecretStr("secret")
|
||||
return pipe
|
||||
|
||||
@staticmethod
|
||||
def context(chat_id="chat-1"):
|
||||
return {
|
||||
"body": {"messages": [{"role": "user", "content": "Fallback"}]},
|
||||
"__user__": {"id": "user-1"},
|
||||
"__metadata__": {
|
||||
"chat_id": chat_id,
|
||||
"user_prompt": "Berechne die Rolle",
|
||||
},
|
||||
}
|
||||
|
||||
async def test_same_openwebui_chat_reuses_rollcalc_conversation(self):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(201, {"conversation_id": "rollcalc-1"}),
|
||||
FakeResponse(200, {"message": "Welcher Kern?"}),
|
||||
FakeResponse(200, {"message": "Berechnung abgeschlossen."}),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
first = await pipe.pipe(**self.context())
|
||||
second_context = self.context()
|
||||
second_context["__metadata__"]["user_prompt"] = "150 mm"
|
||||
second = await pipe.pipe(**second_context)
|
||||
|
||||
self.assertEqual(first, "Welcher Kern?")
|
||||
self.assertEqual(second, "Berechnung abgeschlossen.")
|
||||
self.assertEqual(len(factory.calls), 3)
|
||||
self.assertEqual(
|
||||
factory.calls[1][0],
|
||||
"http://rollcalc.internal:5000/api/conversations/rollcalc-1/messages",
|
||||
)
|
||||
self.assertEqual(factory.calls[2][0], factory.calls[1][0])
|
||||
self.assertEqual(factory.calls[2][1]["json"], {"message": "150 mm"})
|
||||
self.assertEqual(factory.options[0]["auth"].username, "service-user")
|
||||
self.assertEqual(factory.options[0]["auth"].password, "secret")
|
||||
|
||||
async def test_different_chat_gets_different_rollcalc_conversation(self):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(201, {"conversation_id": "rollcalc-1"}),
|
||||
FakeResponse(200, {"message": "Antwort 1"}),
|
||||
FakeResponse(201, {"conversation_id": "rollcalc-2"}),
|
||||
FakeResponse(200, {"message": "Antwort 2"}),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
await pipe.pipe(**self.context("chat-1"))
|
||||
await pipe.pipe(**self.context("chat-2"))
|
||||
|
||||
self.assertEqual(len(pipe._conversations), 2)
|
||||
self.assertEqual(
|
||||
set(pipe._conversations.values()),
|
||||
{"rollcalc-1", "rollcalc-2"},
|
||||
)
|
||||
|
||||
async def test_only_deterministic_message_and_public_pdf_link_are_shown(self):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(201, {"conversation_id": "rollcalc-1"}),
|
||||
FakeResponse(200, {
|
||||
"message": (
|
||||
"Berechnung abgeschlossen.\n\n"
|
||||
"Artikel: Test (180205)\n\n"
|
||||
"PDF: /api/conversations/reports/report-1.pdf"
|
||||
),
|
||||
"pdf": {
|
||||
"url": "/api/conversations/reports/report-1.pdf",
|
||||
},
|
||||
"interpretation": {"internal": "must not leak"},
|
||||
"result": {"calculation": {"average_diameter_mm": 999}},
|
||||
}),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertIn("Berechnung abgeschlossen.", response)
|
||||
self.assertIn(
|
||||
"[PDF herunterladen](https://rollcalc.example.test/"
|
||||
"api/conversations/reports/report-1.pdf)",
|
||||
response,
|
||||
)
|
||||
self.assertNotIn("interpretation", response)
|
||||
self.assertNotIn("999", response)
|
||||
self.assertNotIn("PDF: /api", response)
|
||||
|
||||
async def test_invalid_report_path_is_not_exposed(self):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(201, {"conversation_id": "rollcalc-1"}),
|
||||
FakeResponse(200, {
|
||||
"message": "Berechnung abgeschlossen.",
|
||||
"pdf": {"url": "http://internal-secret/report.pdf"},
|
||||
}),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertEqual(response, "Berechnung abgeschlossen.")
|
||||
|
||||
async def test_expired_conversation_is_removed_without_replaying_message(self):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(201, {"conversation_id": "expired"}),
|
||||
FakeResponse(404, {"status": "conversation_not_found"}),
|
||||
FakeResponse(201, {"conversation_id": "fresh"}),
|
||||
FakeResponse(200, {"message": "Neue Sitzung"}),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
retry = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertIn("abgelaufen", response)
|
||||
self.assertEqual(retry, "Neue Sitzung")
|
||||
self.assertEqual(len(factory.calls), 4)
|
||||
|
||||
async def test_timeout_is_friendly_and_does_not_expose_exception(self):
|
||||
factory = FakeClientFactory(error=_TimeoutException("private details"))
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertIn("zu lange", response)
|
||||
self.assertNotIn("private", response)
|
||||
|
||||
async def test_authentication_and_ollama_errors_are_friendly(self):
|
||||
cases = (
|
||||
(401, "Authentifizierung"),
|
||||
(503, "Spracherkennung"),
|
||||
)
|
||||
for status_code, expected in cases:
|
||||
with self.subTest(status_code=status_code):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(status_code, {"debug": "must not leak"}),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertIn(expected, response)
|
||||
self.assertNotIn("debug", response)
|
||||
|
||||
async def test_validation_error_uses_the_api_deterministic_message(self):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(201, {"conversation_id": "conversation"}),
|
||||
FakeResponse(
|
||||
400,
|
||||
{
|
||||
"status": "invalid_parameter",
|
||||
"message": "Die Eingabe für roll_length_m ist ungültig.",
|
||||
},
|
||||
),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertEqual(response, "Die Eingabe für roll_length_m ist ungültig.")
|
||||
|
||||
async def test_missing_configuration_does_not_make_http_request(self):
|
||||
factory = FakeClientFactory()
|
||||
pipe = PIPE_MODULE.Pipe(client_factory=factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertIn("nicht vollständig konfiguriert", response)
|
||||
self.assertEqual(factory.calls, [])
|
||||
|
||||
async def test_internal_openwebui_task_does_not_touch_conversation(self):
|
||||
factory = FakeClientFactory()
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(
|
||||
**self.context(),
|
||||
__task__="title_generation",
|
||||
)
|
||||
|
||||
self.assertEqual(response, "RollCalc")
|
||||
self.assertEqual(factory.calls, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,231 @@
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from pdf_report import (
|
||||
ReportValidationError,
|
||||
report_from_calculation_result,
|
||||
render_roll_report,
|
||||
safe_report_filename,
|
||||
validate_report_payload,
|
||||
)
|
||||
from roll_calculation import calculate_roll
|
||||
|
||||
try:
|
||||
import app as rollcalc_app
|
||||
except ModuleNotFoundError as error:
|
||||
if error.name not in {"flask", "flask_httpauth", "werkzeug"}:
|
||||
raise
|
||||
rollcalc_app = None
|
||||
|
||||
|
||||
def normal_payload(*, include_weight=True, include_warning=True):
|
||||
payload = {
|
||||
"report_type": "roll_diameter",
|
||||
"article": {"number": "146900", "name": "Stex R 1801, 5.80 x 50 m"},
|
||||
"inputs": [
|
||||
{"label": "Core diameter", "value": "150.0 mm"},
|
||||
{"label": "Core type", "value": "150mm PVC"},
|
||||
{"label": "Product thickness", "value": "10.885 mm"},
|
||||
{"label": "Thickness stddev", "value": "0.427 mm"},
|
||||
{"label": "Product length", "value": "50.00 m"},
|
||||
{"label": "Roll width", "value": "5.80 m"},
|
||||
{"label": "Area weight", "value": "1767.2 g/m²"},
|
||||
],
|
||||
"diameters": {
|
||||
"minimum": {"value": 612.3, "display": "612.3 mm"},
|
||||
"average": {"value": 635.7, "display": "635.7 mm"},
|
||||
"maximum": {"value": 658.1, "display": "658.1 mm"},
|
||||
},
|
||||
"roll_length": {"value": 50.0, "display": "50.00 m"},
|
||||
"roll_weight": (
|
||||
{"value": 512.5, "display": "512.5 kg"} if include_weight else None
|
||||
),
|
||||
"warnings": [],
|
||||
"notes": [],
|
||||
}
|
||||
if include_warning:
|
||||
payload["warnings"].append(
|
||||
{
|
||||
"severity": "warning",
|
||||
"title": "Heavy Roll - Special Equipment Required",
|
||||
"message": "Special equipment required. Core diameter too small.",
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def normal_request(*, include_weight=True):
|
||||
return {
|
||||
"intent": "calculate_roll_diameter",
|
||||
"article_number": "146900",
|
||||
"roll_length_m": 50.0,
|
||||
"width_m": 5.8 if include_weight else None,
|
||||
"core_diameter_mm": 150.0,
|
||||
"category": "bentofix",
|
||||
"include_roll_weight": include_weight,
|
||||
}
|
||||
|
||||
|
||||
@unittest.skipUnless(rollcalc_app, "Flask dependencies are not installed")
|
||||
class PdfEndpointTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.original_log_file = rollcalc_app.LOG_FILE
|
||||
rollcalc_app.LOG_FILE = os.path.join(self.temp_dir.name, "access.json")
|
||||
rollcalc_app.app.config.update(TESTING=True)
|
||||
self.client = rollcalc_app.app.test_client()
|
||||
credentials = base64.b64encode(b"mtazl:rollcalc").decode("ascii")
|
||||
self.auth_headers = {"Authorization": f"Basic {credentials}"}
|
||||
|
||||
def tearDown(self):
|
||||
rollcalc_app.LOG_FILE = self.original_log_file
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def post_report(self, payload):
|
||||
return self.client.post(
|
||||
"/api/reports/roll-calculation.pdf",
|
||||
json=payload,
|
||||
headers=self.auth_headers,
|
||||
)
|
||||
|
||||
def test_normal_request_is_recalculated_into_one_page_pdf(self):
|
||||
response = self.post_report(normal_request())
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.mimetype, "application/pdf")
|
||||
self.assertTrue(response.data.startswith(b"%PDF-1.4"))
|
||||
self.assertEqual(response.data.count(b"/Type /Page "), 1)
|
||||
self.assertIn(b"CALCULATION RESULTS", response.data)
|
||||
self.assertNotIn(b"CALCULATED DIAMETERS", response.data)
|
||||
for expected in (
|
||||
b"813.1 mm",
|
||||
b"845.9 mm",
|
||||
b"877.4 mm",
|
||||
b"50.00 m",
|
||||
b"512.5 kg",
|
||||
b"within standard limits",
|
||||
):
|
||||
self.assertIn(expected, response.data)
|
||||
self.assertGreaterEqual(response.data.count(b"INTERNAL USE ONLY"), 3)
|
||||
self.assertIn("roll-calculation_146900_", response.headers["Content-Disposition"])
|
||||
|
||||
def test_report_without_optional_weight_still_succeeds(self):
|
||||
response = self.post_report(
|
||||
normal_request(include_weight=False)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn(b"CALCULATED ROLL WEIGHT", response.data)
|
||||
self.assertIn(b"845.9 mm", response.data)
|
||||
|
||||
def test_incomplete_or_caller_supplied_results_are_rejected(self):
|
||||
incomplete = normal_request()
|
||||
del incomplete["roll_length_m"]
|
||||
response = self.post_report(incomplete)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.mimetype, "application/json")
|
||||
|
||||
caller_result = normal_request()
|
||||
caller_result["diameters"] = {"average": 1}
|
||||
response = self.post_report(caller_result)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_endpoint_requires_authentication(self):
|
||||
response = self.client.post(
|
||||
"/api/reports/roll-calculation.pdf", json=normal_payload()
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
|
||||
class PdfReportModelTests(unittest.TestCase):
|
||||
def test_low_level_renderer_consumes_validated_layout_model(self):
|
||||
report = validate_report_payload(normal_payload())
|
||||
pdf = render_roll_report(
|
||||
report,
|
||||
generated_at=datetime(2026, 8, 28, 12, 30, tzinfo=timezone.utc),
|
||||
build_info={"version": "v0.4", "commit": "fe267b8"},
|
||||
)
|
||||
|
||||
self.assertIn(b"612.3 mm", pdf)
|
||||
self.assertIn(b"635.7 mm", pdf)
|
||||
self.assertIn(b"658.1 mm", pdf)
|
||||
self.assertIn(b"50.00 m", pdf)
|
||||
|
||||
def test_non_numeric_diameter_is_rejected(self):
|
||||
payload = normal_payload()
|
||||
payload["diameters"]["average"]["value"] = "635.7"
|
||||
with self.assertRaises(ReportValidationError):
|
||||
validate_report_payload(payload)
|
||||
|
||||
def test_display_value_mismatch_is_rejected(self):
|
||||
payload = normal_payload()
|
||||
payload["diameters"]["average"]["display"] = "999.9 mm"
|
||||
with self.assertRaises(ReportValidationError):
|
||||
validate_report_payload(payload)
|
||||
|
||||
def test_effective_roll_length_is_required_and_must_match_display(self):
|
||||
missing = normal_payload()
|
||||
del missing["roll_length"]
|
||||
with self.assertRaises(ReportValidationError):
|
||||
validate_report_payload(missing)
|
||||
|
||||
mismatched = normal_payload()
|
||||
mismatched["roll_length"]["display"] = "60.00 m"
|
||||
with self.assertRaises(ReportValidationError):
|
||||
validate_report_payload(mismatched)
|
||||
|
||||
def test_filename_is_filesystem_safe(self):
|
||||
filename = safe_report_filename(
|
||||
"Article 12/../../unsafe",
|
||||
datetime(2026, 8, 28, tzinfo=timezone.utc),
|
||||
)
|
||||
self.assertEqual(
|
||||
filename,
|
||||
"roll-calculation_Article-12-..-..-unsafe_2026-08-28_000000.pdf",
|
||||
)
|
||||
self.assertNotIn(":", filename)
|
||||
|
||||
def test_pdf_adapter_consumes_authoritative_calculation_result(self):
|
||||
result = calculate_roll(normal_request())
|
||||
report = report_from_calculation_result(result)
|
||||
pdf = render_roll_report(
|
||||
report,
|
||||
generated_at=datetime(2026, 8, 28, 12, 30, tzinfo=timezone.utc),
|
||||
build_info={"version": "v0.4", "commit": "fe267b8"},
|
||||
)
|
||||
|
||||
self.assertIn(b"845.9 mm", pdf)
|
||||
self.assertIn(b"50.00 m", pdf)
|
||||
self.assertIn(b"512.5 kg", pdf)
|
||||
|
||||
def test_multiple_warnings_are_wrapped_without_creating_a_second_page(self):
|
||||
payload = normal_payload()
|
||||
payload["warnings"] = [
|
||||
{
|
||||
"severity": "warning",
|
||||
"title": f"Warning {index}",
|
||||
"message": (
|
||||
"This complete engineering warning remains in the report and "
|
||||
"wraps when the available line width is reached."
|
||||
),
|
||||
}
|
||||
for index in range(1, 7)
|
||||
]
|
||||
report = validate_report_payload(payload)
|
||||
|
||||
pdf = render_roll_report(
|
||||
report,
|
||||
generated_at=datetime(2026, 8, 28, 12, 30, tzinfo=timezone.utc),
|
||||
build_info={"version": "v0.4", "commit": "fe267b8"},
|
||||
)
|
||||
|
||||
self.assertEqual(pdf.count(b"/Type /Page "), 1)
|
||||
for index in range(1, 7):
|
||||
self.assertIn(f"Warning {index}".encode("ascii"), pdf)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,523 @@
|
||||
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()
|
||||
@@ -0,0 +1,44 @@
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class TemplatePdfIntegrationTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.template = (PROJECT_ROOT / "templates" / "roll_calculator.html").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
cls.pdf_report = (PROJECT_ROOT / "pdf_report.py").read_text(encoding="utf-8")
|
||||
|
||||
def test_ui_uses_the_headless_calculation_result(self):
|
||||
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)
|
||||
self.assertNotIn("function calculateDiameterResult", self.template)
|
||||
|
||||
def test_optional_weight_requires_both_weight_inputs(self):
|
||||
self.assertIn(
|
||||
"include_roll_weight: width !== null && areaWeight !== null",
|
||||
self.template,
|
||||
)
|
||||
|
||||
def test_pdf_action_starts_disabled_and_posts_structured_result(self):
|
||||
self.assertIn('id="d-pdf" type="button" disabled', self.template)
|
||||
self.assertIn("body: JSON.stringify(window.CURRENT_ROLL_REQUEST)", self.template)
|
||||
self.assertIn("function invalidatePdfReport()", self.template)
|
||||
|
||||
def test_pdf_layout_uses_four_primary_result_cards(self):
|
||||
self.assertIn('"CALCULATION RESULTS"', self.pdf_report)
|
||||
self.assertIn('(("ROLL", "LENGTH"), report["roll_length"]["display"])', self.pdf_report)
|
||||
|
||||
def test_direct_forklift_display_consumes_service_warning_or_note(self):
|
||||
self.assertIn("renderAuthoritativeForkliftResult(authoritativeResult)", self.template)
|
||||
self.assertIn("const warning = result.warnings?.[0]", self.template)
|
||||
self.assertIn("const note = result.notes?.[0]", self.template)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user