补齐证书齐备、证件折算、移动用证和合同绑定,并带上报销批量发放、付款备注以及个人开发流程升级,便于继续讨论已安排计划的调整规则。 Co-authored-by: Cursor <cursoragent@cursor.com>
183 lines
7.4 KiB
Python
183 lines
7.4 KiB
Python
"""图片解析边界测试,不访问外部服务或数据库。"""
|
|
import base64
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
from decimal import Decimal
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from PIL import Image
|
|
|
|
|
|
def test_recognition_module_exists():
|
|
assert importlib.util.find_spec('app.services.pl_quota_recognition') is not None
|
|
|
|
|
|
def png():
|
|
out = io.BytesIO()
|
|
Image.new('RGB', (64, 64), 'white').save(out, 'PNG')
|
|
return out.getvalue()
|
|
|
|
|
|
def configured(monkeypatch, payload, status=200):
|
|
from app.services import pl_quota_recognition as svc
|
|
monkeypatch.setattr(svc, 'settings', SimpleNamespace(
|
|
DEEPSEEK_API_KEY='test-only', DEEPSEEK_BASE_URL='https://api.deepseek.com',
|
|
DEEPSEEK_VISION_MODEL='deepseek-flash',
|
|
))
|
|
calls = []
|
|
def post(url, **kwargs):
|
|
calls.append((url, kwargs))
|
|
return SimpleNamespace(status_code=status, json=lambda: {
|
|
'choices': [{'finish_reason': 'stop', 'message': {'content': json.dumps(payload)}}]
|
|
})
|
|
monkeypatch.setattr(svc.requests, 'post', post)
|
|
return svc, calls
|
|
|
|
|
|
def test_candidate_fields_only_no_activation_or_link_ids(monkeypatch):
|
|
svc, calls = configured(monkeypatch, {
|
|
'certNo': 'G-1', 'totalQty': '12.123456', 'quantityUnit': '吨',
|
|
'status': 'active', 'usedQty': 0, 'tenantNo': 'other', 'contractId': 'foreign',
|
|
})
|
|
result = svc.recognize_quota_cert('purchase', png())
|
|
assert result['candidates'] == {'certNo': 'G-1', 'totalQty': Decimal('12.123456'), 'quantityUnit': '吨'}
|
|
assert result['requiresConfirmation'] is True
|
|
assert len(calls) == 1
|
|
assert calls[0][1]['allow_redirects'] is False
|
|
request = calls[0][1]['json']
|
|
assert request['response_format'] == {'type': 'json_object'}
|
|
assert request['messages'][1]['content'][1]['image_url']['url'].startswith('data:image/png;base64,')
|
|
|
|
|
|
def test_invalid_field_dropped_without_guessing_other_values(monkeypatch):
|
|
svc, _ = configured(monkeypatch, {'totalQty': 'NaN', 'validTo': 'not-a-date', 'buyerName': '购方'})
|
|
result = svc.recognize_quota_cert('purchase', png())
|
|
assert result['candidates'] == {'buyerName': '购方'}
|
|
assert set(result['invalidFields']) == {'totalQty', 'validTo'}
|
|
|
|
|
|
def test_transport_references_have_no_database_identity(monkeypatch):
|
|
svc, _ = configured(monkeypatch, {'vehicleNumbersJson': ['皖A001', '皖A002'], 'purchaseRefs': [
|
|
{'referenceType': 'purchase_permit', 'referenceNo': 'G-1', 'purchaseCertId': 'injected', 'id': 'injected'}
|
|
]})
|
|
result = svc.recognize_quota_cert('transport', png())
|
|
assert result['candidates']['purchaseRefs'] == [{'referenceType': 'purchase_permit', 'referenceNo': 'G-1'}]
|
|
assert len(result['candidates']['vehicleNumbersJson']) == 2
|
|
|
|
|
|
def test_invalid_image_rejected_before_network(monkeypatch):
|
|
svc, calls = configured(monkeypatch, {})
|
|
with pytest.raises(HTTPException):
|
|
svc.recognize_quota_cert('purchase', b'not an image')
|
|
assert calls == []
|
|
|
|
|
|
def _pdf(page_count):
|
|
"""构造仅含 Helvetica 文本的未加密 PDF,供渲染测试使用。"""
|
|
objects = {
|
|
1: b'<< /Type /Catalog /Pages 2 0 R >>',
|
|
2: ('<< /Type /Pages /Count %d /Kids [%s] >>' % (
|
|
page_count, ' '.join('%d 0 R' % (3 + i * 2) for i in range(page_count))
|
|
)).encode(),
|
|
}
|
|
for index in range(page_count):
|
|
stream = ('BT /F1 18 Tf 36 120 Td (PAGE-%d) Tj ET' % (index + 1)).encode()
|
|
objects[3 + index * 2] = (
|
|
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 200] '
|
|
'/Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> '
|
|
'/Contents %d 0 R >>' % (4 + index * 2)
|
|
).encode()
|
|
objects[4 + index * 2] = b'<< /Length %d >>\nstream\n%s\nendstream' % (len(stream), stream)
|
|
out = bytearray(b'%PDF-1.4\n')
|
|
offsets = [0]
|
|
for number in range(1, len(objects) + 1):
|
|
offsets.append(len(out))
|
|
out += b'%d 0 obj\n%s\nendobj\n' % (number, objects[number])
|
|
xref = len(out)
|
|
out += b'xref\n0 %d\n' % len(offsets)
|
|
out += b'0000000000 65535 f \n'
|
|
for offset in offsets[1:]:
|
|
out += b'%010d 00000 n \n' % offset
|
|
out += b'trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n' % (len(offsets), xref)
|
|
return bytes(out)
|
|
|
|
|
|
def _image_parts(call):
|
|
return [part for part in call[1]['json']['messages'][1]['content'] if part.get('type') == 'image_url']
|
|
|
|
|
|
def test_single_page_pdf_is_sent_as_png(monkeypatch):
|
|
svc, calls = configured(monkeypatch, {'certNo': 'Y34260268310443'})
|
|
result = svc.recognize_quota_cert('transport', _pdf(1))
|
|
assert result['candidates']['certNo'] == 'Y34260268310443'
|
|
images = _image_parts(calls[0])
|
|
assert len(images) == 1
|
|
encoded = images[0]['image_url']['url']
|
|
assert encoded.startswith('data:image/png;base64,')
|
|
with Image.open(io.BytesIO(base64.b64decode(encoded.split(',', 1)[1]))) as image:
|
|
assert image.format == 'PNG'
|
|
assert min(image.size) > 0
|
|
|
|
|
|
def test_multipage_pdf_keeps_page_order(monkeypatch):
|
|
svc, calls = configured(monkeypatch, {'certNo': 'Y1'})
|
|
svc.recognize_quota_cert('transport', _pdf(2))
|
|
assert len(_image_parts(calls[0])) == 2
|
|
assert '按页序' in calls[0][1]['json']['messages'][1]['content'][0]['text']
|
|
|
|
|
|
def test_pdf_over_five_pages_rejected_before_network(monkeypatch):
|
|
svc, calls = configured(monkeypatch, {})
|
|
with pytest.raises(HTTPException) as error:
|
|
svc.recognize_quota_cert('transport', _pdf(6))
|
|
assert calls == []
|
|
assert '超过5页' in error.value.detail
|
|
|
|
|
|
def test_encrypted_pdf_rejected_before_network(monkeypatch):
|
|
import fitz
|
|
document = fitz.open()
|
|
document.new_page()
|
|
raw = document.tobytes(encryption=fitz.PDF_ENCRYPT_AES_256, user_pw='secret', owner_pw='secret')
|
|
document.close()
|
|
svc, calls = configured(monkeypatch, {})
|
|
with pytest.raises(HTTPException) as error:
|
|
svc.recognize_quota_cert('transport', raw)
|
|
assert calls == []
|
|
assert '加密' in error.value.detail
|
|
|
|
|
|
def test_partial_reference_recognition_keeps_readable_numbers(monkeypatch):
|
|
svc, _ = configured(monkeypatch, {'purchaseRefs': [
|
|
{'referenceType': 'purchase_permit', 'referenceNo': 'G-1'},
|
|
{'referenceType': 'unknown', 'referenceNo': None},
|
|
{'referenceType': None, 'referenceNo': 'Readable-2'},
|
|
]})
|
|
result = svc.recognize_quota_cert('transport', png())
|
|
assert result['candidates']['purchaseRefs'] == [
|
|
{'referenceType': 'purchase_permit', 'referenceNo': 'G-1'},
|
|
{'referenceType': 'unknown', 'referenceNo': 'Readable-2'},
|
|
]
|
|
assert 'purchaseRefs[1]' in result['invalidFields']
|
|
|
|
|
|
@pytest.mark.parametrize('status', [401, 429, 500])
|
|
def test_provider_error_is_sanitized_and_allows_manual_entry(monkeypatch, status):
|
|
svc, _ = configured(monkeypatch, {}, status)
|
|
with pytest.raises(HTTPException) as error:
|
|
svc.recognize_quota_cert('purchase', png())
|
|
assert '手工' in error.value.detail
|
|
assert 'test-only' not in error.value.detail
|
|
|
|
|
|
def test_merge_candidates_preserves_user_edits_and_zero():
|
|
from app.services.pl_quota_recognition import merge_unedited_candidates
|
|
assert merge_unedited_candidates({'totalQty': 0, 'buyerName': ''},
|
|
{'totalQty': 100, 'buyerName': '识别名', 'sellerName': '销售方'},
|
|
{'buyerName'}) == {
|
|
'totalQty': 0, 'buyerName': '', 'sellerName': '销售方'
|
|
}
|