test_test_lab.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. """测试工作台确实执行业务、隔离上传资料,并预览实际可下载的结果文件。"""
  2. import asyncio
  3. from copy import copy
  4. from copy import deepcopy
  5. from dataclasses import replace
  6. import json
  7. from pathlib import Path
  8. from xml.etree import ElementTree as ET
  9. from zipfile import ZipFile
  10. import httpx
  11. import pytest
  12. from api.test_lab_routes import TestRequest as LabRequest
  13. from db import business_store, db_conn, test_lab_store
  14. from extensions.main_fan import service as main_fan_service
  15. from main import app
  16. from runtime import business_tasks
  17. from runtime.business_tasks import BusinessTaskController
  18. from runtime.test_lab import service
  19. from runtime.test_lab.presets import main_fan_input
  20. from runtime.test_lab.spreadsheets import NS, preview_workbook, write_workbook
  21. from runtime.test_lab.main_fan_simulation import MainFanSimulation, build_input
  22. @pytest.fixture
  23. def controller(monkeypatch,tmp_path):
  24. # 下列旧规则/导出兼容用例显式挂载历史服务;生产默认没有 pipeline test_runner。
  25. from extensions import registry as extensions
  26. catalog = copy(extensions.load_extensions())
  27. catalog.business_agents = dict(catalog.business_agents)
  28. spec = catalog.business_agents['main_fan']
  29. catalog.business_agents['main_fan'] = replace(spec, test_runner=main_fan_service.analyze)
  30. monkeypatch.setattr(extensions, '_loaded', catalog)
  31. monkeypatch.setenv('TEST_LAB_ENABLED','1')
  32. monkeypatch.setenv('TEST_LAB_DATA_DIR',str(tmp_path/'assets'))
  33. monkeypatch.setenv('TEST_LAB_SAMPLE_DIR',str(tmp_path/'samples'))
  34. value=BusinessTaskController()
  35. monkeypatch.setattr(business_tasks,'_controller',value)
  36. return value
  37. def request(scenario='normal',**extra):
  38. extra.setdefault('execution_mode', 'pipeline') # 显式测试兼容 runner;默认 API 已是完整 Agent。
  39. return LabRequest(agent_name='main_fan',scenario_id=scenario,label=scenario,input=main_fan_input(scenario),**extra)
  40. @pytest.mark.asyncio
  41. @pytest.mark.parametrize('scenario,expected',[('normal','NORMAL'),('warning','WARNING'),('abnormal','ABNORMAL'),('insufficient','UNDETERMINED')])
  42. async def test_runs_use_real_business_rules_and_preserve_request_and_result(controller,scenario,expected):
  43. value=service.start_run(request(scenario),'lab-user')
  44. job=business_store.claim('main_fan')
  45. assert job['id']==value['id']
  46. await controller.run_job(job)
  47. result=service.public_run(value['id'],'lab-user')
  48. assert result['status']=='completed',result['error']
  49. assert result['result']['platform_payload']['judgment']['resultCode']==expected
  50. assert result['result']['assessment']['decision']['result_code']==expected
  51. assert result['request']['input']['reports']==value['request']['input']['reports']
  52. assert result['request']['publish'] is False
  53. assert business_store.get(job['id'])['push_status']=='none'
  54. assert len(result['stages'])==7
  55. assert service.public_run(value['id'],'lab-user')==result
  56. def test_test_request_retries_are_idempotent_and_changed_inputs_are_rejected(controller):
  57. payload=request(request_id='same-request')
  58. first=service.start_run(payload,'lab-user')
  59. second=service.start_run(payload,'lab-user')
  60. assert first['id']==second['id'] and first['session_id']==second['session_id']
  61. assert db_conn.scalar('SELECT COUNT(*) FROM test_lab_runs',())==1
  62. changed=payload.model_copy(deep=True)
  63. changed.input['reports'][1]['model']='DIFFERENT'
  64. with pytest.raises(ValueError,match='不同'):
  65. service.start_run(changed,'lab-user')
  66. @pytest.mark.asyncio
  67. async def test_upload_content_json_preview_and_cross_user_access(controller):
  68. transport=httpx.ASGITransport(app=app)
  69. async with httpx.AsyncClient(transport=transport,base_url='http://test') as http:
  70. response=await http.post('/api/test-lab/assets?user_name=alice',files={'file':('../资料.json',json.dumps(main_fan_input('normal'),ensure_ascii=False).encode(),'application/json')})
  71. assert response.status_code==201,response.text
  72. asset=response.json()
  73. assert asset['filename']=='资料.json'
  74. assert (await http.get(asset['preview_url']+'?user_name=alice')).json()['content']['fan_id']=='mock-report-fan-1'
  75. downloaded=await http.get(asset['url']+'?user_name=alice')
  76. assert downloaded.status_code==200
  77. assert downloaded.headers['x-content-type-options']=='nosniff'
  78. assert (await http.get(asset['url']+'?user_name=bob')).status_code==404
  79. assert (await http.get('/api/test-lab/assets/not-an-id/content?user_name=alice')).status_code==404
  80. assert (await http.post('/api/test-lab/assets',files={'file':('bad.json',b'{bad','application/json')})).status_code==400
  81. assert (await http.post('/api/test-lab/assets',files={'file':('code.html',b'<script>alert(1)</script>','text/html')})).status_code==400
  82. @pytest.mark.asyncio
  83. async def test_pdf_selection_does_not_mix_synthetic_report_facts(controller,tmp_path):
  84. transport=httpx.ASGITransport(app=app)
  85. async with httpx.AsyncClient(transport=transport,base_url='http://test') as http:
  86. upload=await http.post('/api/test-lab/assets',files={'file':('测试.pdf',b'%PDF-1.4\nsynthetic unit-test file','application/pdf')})
  87. identifier=upload.json()['id']
  88. payload=request(asset_ids=[identifier]).model_dump(mode='json')
  89. response=await http.post('/api/test-lab/runs',json=payload)
  90. assert response.status_code==400 and '混用' in response.text
  91. payload=request('documents',asset_ids=[identifier]).model_dump(mode='json')
  92. response=await http.post('/api/test-lab/runs',json=payload)
  93. assert response.status_code==202,response.text
  94. created=response.json()
  95. assert created['request']['input']['reports']==[]
  96. assert len(created['request']['input']['materials'])==1
  97. assert created['request']['assets'][0]['sha256']
  98. assert 'tmp_path' not in response.text
  99. record=business_store.get(created['id'])
  100. assert record['source']=='test_pipeline' and record['session_id']
  101. assert (await http.get('/api/test-lab/runs/'+created['id']+'?user_name=someone-else')).status_code==404
  102. @pytest.mark.asyncio
  103. async def test_uploaded_existing_mock_curves_replace_the_preset_series_without_guessing(controller):
  104. transport=httpx.ASGITransport(app=app)
  105. async with httpx.AsyncClient(transport=transport,base_url='http://test') as http:
  106. ids=[]
  107. for name in ('mock_switch_states.json','mock_airflow_series.json'):
  108. data=(Path(__file__).parents[1]/'examples/main_fan'/name).read_bytes()
  109. reply=await http.post('/api/test-lab/assets',files={'file':(name,data,'application/json')})
  110. assert reply.status_code==201
  111. ids.append(reply.json()['id'])
  112. payload=request('abnormal',asset_ids=ids).model_dump(mode='json')
  113. response=await http.post('/api/test-lab/runs',json=payload)
  114. assert response.status_code==202,response.text
  115. await controller.run_job(business_store.claim('main_fan'))
  116. actual=(await http.get('/api/test-lab/runs/'+response.json()['id'])).json()
  117. assert actual['result']['platform_payload']['judgment']['resultCode']=='NORMAL'
  118. event=actual['result']['assessment']['switch_comparison']['events'][0]
  119. assert event['metrics']['flow_deviation_percent']==pytest.approx(5)
  120. assert event['metrics']['switch_interval_minutes']==6
  121. @pytest.mark.asyncio
  122. async def test_excel_download_and_preview_match_canonical_judgment(controller):
  123. value=service.start_run(request(),'lab-user')
  124. await controller.run_job(business_store.claim('main_fan'))
  125. transport=httpx.ASGITransport(app=app)
  126. async with httpx.AsyncClient(transport=transport,base_url='http://test') as http:
  127. run=(await http.get(f"/api/test-lab/runs/{value['id']}?user_name=lab-user")).json()
  128. item=run['artifacts'][0]
  129. preview=await http.get(item['preview_url']+'?user_name=lab-user')
  130. assert preview.status_code==200,preview.text
  131. workbook=preview.json()
  132. assert workbook['origin']=='generated'
  133. assert [s['name'] for s in workbook['sheets']]==['研判结果','性能曲线比较']
  134. rows=workbook['sheets'][0]['rows']
  135. assert any(row and row[0]=='额定转速(r/min)' for row in rows)
  136. assert rows[4][1]==run['result']['platform_payload']['judgment']['resultCode']=='NORMAL'
  137. assert rows[9][1]==6 and rows[10][1]==pytest.approx(5)
  138. downloaded=await http.get(item['url']+'?user_name=lab-user')
  139. assert downloaded.status_code==200 and downloaded.content.startswith(b'PK')
  140. assert 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' in downloaded.headers['content-type']
  141. actual=service.artifact(run['id'],'result_excel','lab-user')
  142. with ZipFile(actual['path']) as archive:
  143. root=ET.fromstring(archive.read('xl/worksheets/sheet1.xml'))
  144. assert root.find(f".//{{{NS}}}c[@r='B5']/{{{NS}}}is/{{{NS}}}t").text=='NORMAL'
  145. assert float(root.find(f".//{{{NS}}}c[@r='B10']/{{{NS}}}v").text)==6
  146. assert root.findall(f'.//{{{NS}}}f')==[]
  147. assert (await http.get(item['url']+'?user_name=other')).status_code==404
  148. def test_excel_preview_reads_cached_formulas_as_data_and_bounds_rows_and_columns(tmp_path):
  149. path=tmp_path/'external.xlsx'
  150. write_workbook(path,[{'name':'外部附件','rows':[['=HYPERLINK("https://invalid")',100],['后续',200],['第三行',300]],'widths':[30,20],'headers':[1]}])
  151. actual=preview_workbook(path,max_rows=2,max_cols=1)
  152. assert actual['sheets'][0]['rows']==[['=HYPERLINK("https://invalid")'],['后续']]
  153. assert actual['sheets'][0]['total_rows']==3 and actual['sheets'][0]['truncated']
  154. with ZipFile(path) as archive:
  155. assert ET.fromstring(archive.read('xl/worksheets/sheet1.xml')).findall(f'.//{{{NS}}}f')==[]
  156. @pytest.mark.asyncio
  157. async def test_actual_returned_excel_is_available_alongside_generated_export_and_cannot_escape_session(controller,tmp_path):
  158. from agents.workspace import session_root
  159. from runtime.business_models import ResultArtifact
  160. created=service.start_run(request(),'lab-user')
  161. await controller.run_job(business_store.claim('main_fan'))
  162. job=business_store.get(created['id'])
  163. result=json.loads(job['result_json'])
  164. folder=session_root('lab-user',job['session_id'])/'workspace/results'
  165. folder.mkdir(parents=True,exist_ok=True)
  166. write_workbook(folder/'actual.xlsx',[{'name':'实际返回数据','rows':[['风量(m³/min)'],[3800]],'headers':[1],'widths':[25]}])
  167. # 用接口契约模拟一个实际生成文件的业务工具;主通风机判识数据保持原样。
  168. result['artifacts']=[ResultArtifact(filename='实际返回.xlsx',path='/workspace/results/actual.xlsx').model_dump(),
  169. {'filename':'越界文件.xlsx','path':'/workspace/../../outside.xlsx'}]
  170. business_store.update(job['id'],result_json=json.dumps(result,ensure_ascii=False))
  171. record=service.public_run(job['id'],'lab-user')
  172. assert [a['origin'] for a in record['artifacts']]==['returned','generated']
  173. item=service.artifact(job['id'],record['artifacts'][0]['id'],'lab-user')
  174. assert preview_workbook(item['path'])['sheets'][0]['rows'][1][0]==3800
  175. with pytest.raises(ValueError):
  176. service.artifact(job['id'],record['artifacts'][0]['id'],'other-user')
  177. @pytest.mark.asyncio
  178. async def test_queue_cancel_only_cancels_the_selected_test(controller):
  179. first=service.start_run(request(),'lab-user')
  180. second=service.start_run(request(),'lab-user')
  181. await controller.cancel_job(first['id'])
  182. assert service.public_run(first['id'],'lab-user')['status']=='cancelled'
  183. assert service.public_run(second['id'],'lab-user')['status']=='queued'
  184. @pytest.mark.asyncio
  185. async def test_full_agent_mode_still_uses_the_existing_orchestrator(controller):
  186. from runtime.engine import ExecutionResult
  187. calls=[]
  188. async def execute(**kwargs):
  189. calls.append(kwargs)
  190. controlled=kwargs['graph_config']['configurable']
  191. result=await main_fan_service.analyze(controlled['business_task_input'],kwargs['user_name'],kwargs['session_id'],job_id=controlled['business_task_id'])
  192. return ExecutionResult('test-run',kwargs['session_id'],'success',result['result'],'',{},[],[],result)
  193. controller.executor=execute
  194. created=service.start_run(request(execution_mode='agent'),'lab-user')
  195. await controller.run_job(business_store.claim('main_fan'))
  196. assert len(calls)==1 and calls[0]['agent_name']=='main_fan'
  197. assert service.public_run(created['id'],'lab-user')['status']=='completed'
  198. @pytest.mark.asyncio
  199. async def test_workbench_can_be_disabled_without_changing_chat_or_business_api(controller,monkeypatch):
  200. monkeypatch.setenv('TEST_LAB_ENABLED','0')
  201. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app),base_url='http://test') as http:
  202. assert (await http.get('/api/test-lab/catalog')).status_code==404
  203. assert (await http.get('/api/business-agents')).status_code==200
  204. @pytest.mark.parametrize('interval,before,after,direction,code',[
  205. (6,3800,3610,'1_to_2','NORMAL'), (6.5,3800,3344,'1_to_2','WARNING'),
  206. (10,3800,3420,'2_to_1','NORMAL'), (10.5,3800,3610,'2_to_1','WARNING'),
  207. (0,3800,3230,'1_to_2','ABNORMAL'), (120,3800,3040,'1_to_2','ABNORMAL'),
  208. (6,3800,4180,'1_to_2','NORMAL'), (6,3800,4370,'1_to_2','ABNORMAL'),
  209. ])
  210. def test_three_input_generator_preserves_interval_flow_direction_and_boundaries(interval,before,after,direction,code):
  211. from algorithms.main_fan.models import ScreeningInput
  212. from algorithms.main_fan.equivalence import normalize_and_validate_fan_data, analyze_fan_switch_event
  213. from extensions.main_fan.pipeline import load_rules, run_pipeline
  214. settings=MainFanSimulation(switch_interval_minutes=interval,before_flow_m3_min=before,after_flow_m3_min=after,direction=direction)
  215. raw=build_input(settings)
  216. assert raw['reports']==[] and raw['use_gateway'] is False and raw['document_cache_policy']=='off'
  217. # 仅算法回归使用明确的合成报告,不会进入网页三项输入请求。
  218. raw['reports']=main_fan_input('normal')['reports']
  219. data=ScreeningInput.model_validate({k:v for k,v in raw.items() if k in ScreeningInput.model_fields})
  220. dataset=normalize_and_validate_fan_data(data,load_rules())
  221. switch=analyze_fan_switch_event(dataset)
  222. assert len(switch['events'])==1
  223. event=switch['events'][0]
  224. assert event['metrics']['switch_interval_minutes']==interval
  225. assert event['metrics']['flow_deviation_percent']==pytest.approx(abs(after-before)/before*100)
  226. assert event['from_fan_id']==('mock-report-fan-2' if direction=='2_to_1' else 'mock-report-fan-1')
  227. assessment,_,_=run_pipeline({'data':data.model_dump(mode='json'),
  228. 'analysis':main_fan_service._screen(data).model_dump(mode='json'),
  229. 'policy':load_rules().model_dump(mode='json')})
  230. assert assessment['decision']['result_code']==code
  231. @pytest.mark.asyncio
  232. async def test_three_inputs_always_start_fresh_agent_and_workspace_without_document_cache(controller,monkeypatch):
  233. from agents.workspace import session_root
  234. from algorithms.main_fan.models import DocumentFacts
  235. from db import chat_store
  236. from runtime.engine import ExecutionResult
  237. http=httpx.AsyncClient(transport=httpx.ASGITransport(app=app),base_url='http://test')
  238. upload=await http.post('/api/test-lab/assets',files={'file':('双机报告.pdf',b'%PDF-1.4\nunit test fixture','application/pdf')})
  239. asset=upload.json()['id']
  240. calls=[]
  241. async def read(path,virtual,**kwargs):
  242. calls.append(kwargs)
  243. return DocumentFacts(reports=main_fan_input('normal')['reports'],extraction_audit={'cache_used':False,'cache_policy':kwargs['cache_policy']})
  244. monkeypatch.setattr(main_fan_service,'read_document',read)
  245. seen=[]
  246. async def execute(**kwargs):
  247. root=session_root(kwargs['user_name'],kwargs['session_id'])
  248. assert not (root/'workspace/previous-run.txt').exists()
  249. assert chat_store.get_messages(kwargs['session_id'])==[]
  250. controlled=kwargs['graph_config']['configurable']
  251. assert controlled['business_task_input']['reports']==[]
  252. seen.append(root)
  253. result=await main_fan_service.analyze(controlled['business_task_input'],kwargs['user_name'],kwargs['session_id'],job_id=controlled['business_task_id'])
  254. (root/'workspace/previous-run.txt').write_text('仅供本次工作区使用')
  255. return ExecutionResult('unit-test',kwargs['session_id'],'success',result['result'],'',{},[],[],result)
  256. controller.executor=execute
  257. records=[]
  258. for after,code in [(3610,'NORMAL'),(3344,'WARNING')]:
  259. response=await http.post('/api/test-lab/runs',json={
  260. 'agent_name':'main_fan','scenario_id':'three_inputs','label':'三项输入', 'input':{},
  261. 'asset_ids':[asset],'execution_mode':'agent',
  262. 'main_fan_simulation':{'after_flow_m3_min':after}})
  263. assert response.status_code==202,response.text
  264. created=response.json()
  265. await controller.run_job(business_store.claim('main_fan'))
  266. actual=service.public_run(created['id'],'default')
  267. assert actual['status']=='completed',actual['error']
  268. assert actual['result']['platform_payload']['judgment']['resultCode']==code
  269. assert actual['request']['isolation']['history_reused'] is False
  270. assert actual['request']['input']['document_cache_policy']=='off'
  271. assert (seen[-1]/'workspace/test_inputs/主备风机开停曲线.json').is_file()
  272. records.append(actual)
  273. assert records[0]['session_id']!=records[1]['session_id'] and seen[0]!=seen[1]
  274. assert len(calls)==2 and all(c['cache_policy']=='off' for c in calls)
  275. assert db_conn.scalar('SELECT COUNT(*) FROM main_fan_documents',())==0
  276. await http.aclose()
  277. @pytest.mark.asyncio
  278. async def test_three_inputs_require_pdf_full_agent_and_refuse_hidden_report_data(controller):
  279. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app),base_url='http://test') as http:
  280. body={'agent_name':'main_fan','input':{},'execution_mode':'agent','main_fan_simulation':{}}
  281. missing=await http.post('/api/test-lab/runs',json=body)
  282. assert missing.status_code==400 and 'PDF' in missing.text
  283. body['execution_mode']='pipeline'
  284. wrong_mode=await http.post('/api/test-lab/runs',json=body)
  285. assert wrong_mode.status_code==400 and '完整智能体' in wrong_mode.text
  286. body['execution_mode']='agent'
  287. body['input']={'reports':main_fan_input('normal')['reports']}
  288. injected=await http.post('/api/test-lab/runs',json=body)
  289. assert injected.status_code==400 and '混入' in injected.text
  290. body['input']={}
  291. body['main_fan_simulation']={'switch_source':'file'}
  292. invalid=await http.post('/api/test-lab/runs',json=body)
  293. assert invalid.status_code==422
  294. @pytest.mark.asyncio
  295. async def test_three_input_slots_replace_entire_curve_inputs_and_reject_swapped_files(controller):
  296. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app),base_url='http://test') as http:
  297. pdf=(await http.post('/api/test-lab/assets',files={'file':('双机.pdf',b'%PDF-1.4\nunit test fixture','application/pdf')})).json()['id']
  298. curves=[]
  299. for name in ('mock_switch_states.json','mock_airflow_series.json'):
  300. contents=(Path(__file__).parents[1]/'examples/main_fan'/name).read_bytes()
  301. uploaded=await http.post('/api/test-lab/assets',files={'file':(name,contents,'application/json')})
  302. curves.append(uploaded.json()['id'])
  303. body={'agent_name':'main_fan','input':{},'execution_mode':'agent','asset_ids':[pdf],
  304. 'main_fan_simulation':{'switch_source':'file','flow_source':'file',
  305. 'switch_asset_id':curves[0],'flow_asset_id':curves[1]}}
  306. response=await http.post('/api/test-lab/runs',json=body)
  307. assert response.status_code==202,response.text
  308. actual=response.json()['request']
  309. assert len(actual['assets'])==3 and actual['input']['reports']==[]
  310. for key in ('fan_state_records','operating_series'):
  311. assert actual['input'][key]
  312. assert all(row['evidence']['source'].startswith('test-lab://assets/') for row in actual['input'][key])
  313. body['main_fan_simulation']['switch_asset_id'],body['main_fan_simulation']['flow_asset_id']=curves[1],curves[0]
  314. swapped=await http.post('/api/test-lab/runs',json=body)
  315. assert swapped.status_code==400 and '对应的时序' in swapped.text