feat: add conversational RollCalc assistant
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user