122 lines
7.1 KiB
Python
122 lines
7.1 KiB
Python
import base64
|
|||
|
|
import json
|
||
|
|
import sys
|
||
|
|
import unittest
|
||
|
|
from email import policy
|
||
|
|
from email.parser import BytesParser
|
||
|
|
from pathlib import Path
|
||
|
|
from unittest.mock import MagicMock, patch
|
||
|
|
|
||
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'server'))
|
||
|
|
import server as app
|
||
|
|
import ai_client
|
||
|
|
|
||
|
|
|
||
|
|
class ReferenceContractTest(unittest.TestCase):
|
||
|
|
def test_original_analysis_survives_rewritten_report_roles(self):
|
||
|
|
report = {
|
||
|
|
'facts': {'images': {'main': ['https://example.test/1.png', 'https://example.test/2.png']}},
|
||
|
|
'evidenceLedger': [
|
||
|
|
{'id': f'IMG_MAIN_{i:04d}', 'type': 'image', 'value': f'https://example.test/{i}.png', 'meta': {'group': 'main'}}
|
||
|
|
for i in (1, 2)
|
||
|
|
],
|
||
|
|
'experienceSolution': {'visualCommerce': {'items': [
|
||
|
|
{'imageEvidenceId': 'IMG_MAIN_0002', 'imageRole': '使用场景', 'visualSignals': ['四季通用']}
|
||
|
|
], 'evidenceDetail': []}},
|
||
|
|
'visualDecision': {
|
||
|
|
'imageRoles': [
|
||
|
|
{'imageEvidenceId': 'IMG_MAIN_0002', 'imageRole': '材质触感', 'visualSignals': ['面料细节'], 'businessMeaning': '面料特写'}
|
||
|
|
],
|
||
|
|
'evidenceDetail': [
|
||
|
|
{'imageEvidenceId': 'IMG_MAIN_0002', 'composition': '面料特写背景,中央信息卡片', 'textInfo': '面料细节'}
|
||
|
|
],
|
||
|
|
},
|
||
|
|
}
|
||
|
|
observation = report['visualDecision']['evidenceDetail'][0]
|
||
|
|
observation['textInfo'] = '面料细节' + '完整参考文字' * 60
|
||
|
|
observation['composition'] += '保持原始布局' * 100
|
||
|
|
observation['textLayout'] = {'position': '中央', 'alignment': 'left', 'lines': 3}
|
||
|
|
observation['displayRelations'] = {'visibleParts':[{'id':'a','description':'上层可见区域'},{'id':'b','description':'回折区域'}],
|
||
|
|
'relations':[{'type':'连接','from':'a','to':'b','observation':'左侧连续回折,不是独立叠放'}]}
|
||
|
|
role = report['visualDecision']['imageRoles'][0]
|
||
|
|
role['visualSignals'] = ['面料细节'] + ['完整分析' * 30] * 6
|
||
|
|
slot = app.build_generation_slots(report, {})[1]
|
||
|
|
self.assertEqual('材质触感', slot['referenceAnalysis']['theme'])
|
||
|
|
self.assertEqual(observation['textInfo'], slot['referenceTextInfo'])
|
||
|
|
self.assertEqual(observation['composition'], slot['referenceAnalysis']['composition'])
|
||
|
|
self.assertEqual(observation, slot['referenceAnalysis']['imageObservation'])
|
||
|
|
self.assertEqual(role, slot['referenceAnalysis']['imageRoleAnalysis'])
|
||
|
|
self.assertEqual(role['visualSignals'], slot['referenceAnalysis']['signals'])
|
||
|
|
self.assertEqual(observation['displayRelations'],slot['referenceAnalysis']['displayRelations'])
|
||
|
|
prompt = app.build_image_prompt(report, {}, slot=slot, variant_index=1,
|
||
|
|
product_reference_mode='uploaded_identity_collected_reference', match_reference_shooting=True)
|
||
|
|
self.assertIn('原始图片主题:材质触感', prompt)
|
||
|
|
self.assertIn('面料特写背景,中央信息卡片', prompt)
|
||
|
|
self.assertNotIn('唯一任务:', prompt)
|
||
|
|
self.assertIn(observation['textInfo'], prompt)
|
||
|
|
self.assertIn(observation['composition'], prompt)
|
||
|
|
self.assertIn('左侧连续回折,不是独立叠放',prompt)
|
||
|
|
|
||
|
|
def test_reference_download_uses_certificate_context_and_exact_bytes(self):
|
||
|
|
image = b'\x89PNG\r\n\x1a\nimage-content'
|
||
|
|
with patch.object(app, '_download_image', return_value=(image, 'image/png')):
|
||
|
|
data_url = app._reference_data_url('https://example.test/reference.png')
|
||
|
|
self.assertEqual(image, base64.b64decode(data_url.split(',', 1)[1]))
|
||
|
|
response = MagicMock()
|
||
|
|
response.__enter__.return_value = response
|
||
|
|
response.read.return_value = image
|
||
|
|
response.headers.get.return_value = 'image/png'
|
||
|
|
with patch.object(app, '_ssl_context', return_value='trusted-context'), \
|
||
|
|
patch.object(app, 'load_config', return_value={}), \
|
||
|
|
patch.object(app.urllib.request, 'urlopen', return_value=response) as open_url:
|
||
|
|
self.assertEqual(image, app._download_image('https://example.test/reference.png')[0])
|
||
|
|
self.assertEqual('trusted-context', open_url.call_args.kwargs['context'])
|
||
|
|
|
||
|
|
def test_apiyi_request_contains_ordered_image_files_and_complete_prompt(self):
|
||
|
|
images = [b'\x89PNG\r\n\x1a\nproduct-image', b'\x89PNG\r\n\x1a\ncollected-image']
|
||
|
|
refs = ['data:image/png;base64,' + base64.b64encode(data).decode() for data in images]
|
||
|
|
prompt = '图1提供产品身份;图2为展示母版。\n保持折叠状态和文字排版。'
|
||
|
|
config = {'api_base': 'https://api.apiyi.com/v1', 'api_key': 'test-key',
|
||
|
|
'image_model': 'gpt-image-2', 'image_path': '/images/generations',
|
||
|
|
'timeout': 10, 'retries': 0}
|
||
|
|
response = MagicMock()
|
||
|
|
response.__enter__.return_value = response
|
||
|
|
response.read.return_value = json.dumps({'data': [{'b64_json': 'result'}]}).encode()
|
||
|
|
with patch.object(ai_client, 'image_channel', return_value=config), \
|
||
|
|
patch.object(ai_client, '_ssl_context', return_value='trusted-context'), \
|
||
|
|
patch.object(ai_client.urllib.request, 'urlopen', return_value=response) as open_url:
|
||
|
|
result = ai_client.image_generate(prompt, model='gpt-image-2', size='1024x1024', quality='high', reference_images=refs)
|
||
|
|
self.assertEqual('result', result['data'][0]['b64_json'])
|
||
|
|
request = open_url.call_args.args[0]
|
||
|
|
self.assertEqual('https://api.apiyi.com/v1/images/edits', request.full_url)
|
||
|
|
content_type = request.get_header('Content-type')
|
||
|
|
message = BytesParser(policy=policy.default).parsebytes(
|
||
|
|
('Content-Type: ' + content_type + '\r\nMIME-Version: 1.0\r\n\r\n').encode() + request.data)
|
||
|
|
fields = {}
|
||
|
|
files = []
|
||
|
|
for part in message.iter_parts():
|
||
|
|
name = part.get_param('name', header='content-disposition')
|
||
|
|
data = part.get_payload(decode=True)
|
||
|
|
if name == 'image[]':
|
||
|
|
files.append(data)
|
||
|
|
else:
|
||
|
|
fields[name] = data.decode('utf-8')
|
||
|
|
self.assertEqual(images, files)
|
||
|
|
self.assertEqual(prompt, fields['prompt'])
|
||
|
|
self.assertEqual({'model': 'gpt-image-2', 'size': '1024x1024', 'quality': 'high', 'n': '1'},
|
||
|
|
{key: value for key, value in fields.items() if key != 'prompt'})
|
||
|
|
|
||
|
|
def test_invalid_reference_never_sends_request(self):
|
||
|
|
config = {'api_base': 'https://api.apiyi.com/v1', 'api_key': 'test-key',
|
||
|
|
'image_model': 'gpt-image-2', 'image_path': '/images/generations',
|
||
|
|
'timeout': 10, 'retries': 0}
|
||
|
|
with patch.object(ai_client, 'image_channel', return_value=config), \
|
||
|
|
patch.object(ai_client.urllib.request, 'urlopen') as open_url:
|
||
|
|
with self.assertRaises(RuntimeError):
|
||
|
|
ai_client.image_generate('保留展示状态', reference_images=['data:image/png;base64,aW52YWxpZA=='])
|
||
|
|
open_url.assert_not_called()
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
unittest.main()
|