feat: add conversational RollCalc assistant

This commit is contained in:
2026-08-29 22:37:56 +02:00
parent 3b44048250
commit fa3000b325
25 changed files with 6820 additions and 48 deletions
+67
View File
@@ -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()
+112
View File
@@ -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()
+852
View File
@@ -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()
+67
View File
@@ -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()
+316
View File
@@ -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()
+27
View File
@@ -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()
+289
View File
@@ -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()
+231
View File
@@ -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()
+523
View File
@@ -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()
+44
View File
@@ -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()