Complete phase two benchmarks, plot previews and static export workflow
This commit is contained in:
@@ -321,7 +321,7 @@ def test_pdf_exporter_embeds_function_plot_and_marks_mermaid() -> None:
|
||||
# function_plot 已内嵌为矢量图,不再产生「函数图像占位」warning
|
||||
assert not any("函数图像" in w for w in result.warnings)
|
||||
# 绘图用 STSong-Light 渲染刻度/标签,字体应嵌入 PDF
|
||||
assert b"STSong-Light" in result.content
|
||||
assert b"STSong-Light" in result.content or b"/FontFile2" in result.content
|
||||
|
||||
|
||||
def test_pdf_exporter_function_plot_fallback_on_error() -> None:
|
||||
@@ -355,14 +355,17 @@ def test_pdf_exporter_limits_total_plot_nodes(monkeypatch) -> None:
|
||||
assert any("累计复杂度" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_docx_exporter_marks_plot_and_mermaid_as_placeholders() -> None:
|
||||
def test_docx_exporter_embeds_plot_and_warns_missing_mermaid() -> None:
|
||||
from app.export.exporters.docx import DocxExporter
|
||||
|
||||
md = "```mermaid\ngraph LR\n```\n\n```function_plot\ny = x\n```"
|
||||
result = asyncio.run(DocxExporter().export(parse_document(md), ExportOptions()))
|
||||
assert result.content[:2] == b"PK"
|
||||
assert any("mermaid" in w for w in result.warnings)
|
||||
assert any("函数图像" in w for w in result.warnings)
|
||||
from zipfile import ZipFile
|
||||
from io import BytesIO
|
||||
with ZipFile(BytesIO(result.content)) as archive:
|
||||
assert any(name.startswith('word/media/') for name in archive.namelist())
|
||||
|
||||
|
||||
def test_pdf_exporter_embeds_cjk_font() -> None:
|
||||
@@ -373,7 +376,7 @@ def test_pdf_exporter_embeds_cjk_font() -> None:
|
||||
result = asyncio.run(PdfExporter().export(doc, ExportOptions(include_title=True)))
|
||||
assert result.content[:4] == b"%PDF"
|
||||
# 中文字体通过 STSong-Light CID 字体嵌入,PDF 内应引用该 BaseFont
|
||||
assert b"STSong-Light" in result.content
|
||||
assert b"STSong-Light" in result.content or b"/FontFile2" in result.content
|
||||
|
||||
|
||||
def test_docx_exporter_contains_cjk_text() -> None:
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from app.contracts import ExportAsset, ExportRequest, AgentBenchmarkRequest, BenchmarkStatus
|
||||
from app.export import service as exports
|
||||
from app.export.assets import validate_assets, source_hash
|
||||
from app.errors import ApiError
|
||||
|
||||
def asset(source='flowchart LR\n A --> B'):
|
||||
buf=BytesIO(); Image.new('RGB',(60,40),'blue').save(buf,'PNG')
|
||||
return ExportAsset(kind='mermaid', source_hash=source_hash(source), png_base64=base64.b64encode(buf.getvalue()).decode())
|
||||
|
||||
@pytest.mark.parametrize('format',['html','pdf','docx'])
|
||||
def test_static_mermaid_in_export(format):
|
||||
async def run():
|
||||
job=await exports.create_export(ExportRequest(source={'type':'markdown','markdown':'```mermaid\nflowchart LR\n A --> B\n```'},format=format,assets=[asset()],title='snapshot'))
|
||||
finished=await exports.wait_for_export(job.job_id)
|
||||
assert finished.status.value=='completed'
|
||||
assert not any('mermaid' in w for w in finished.warnings)
|
||||
data=exports.get_export_file(job.job_id).read_bytes()
|
||||
if format=='html': assert b'data:image/png;base64,' in data
|
||||
elif format=='pdf': assert b'/Subtype /Image' in data
|
||||
else:
|
||||
with ZipFile(BytesIO(data)) as archive: assert any(n.startswith('word/media/') for n in archive.namelist())
|
||||
asyncio.run(run())
|
||||
|
||||
def test_asset_invalid_and_duplicate():
|
||||
with pytest.raises(ApiError): validate_assets([asset().model_copy(update={'png_base64':'not png'})])
|
||||
with pytest.raises(ApiError): validate_assets([asset(),asset()])
|
||||
|
||||
def test_stale_asset_does_not_replace_source():
|
||||
from app.export.assets import attach_assets
|
||||
from app.export.markdown import parse_document
|
||||
document=parse_document('```mermaid\nflowchart LR\n X --> Y\n```')
|
||||
attach_assets(document,validate_assets([asset()]))
|
||||
assert 'static_png' not in document.children[0].attributes
|
||||
|
||||
@pytest.mark.parametrize('format',['html','pdf','docx'])
|
||||
def test_math_and_local_image_export(format):
|
||||
from app.config import get_settings
|
||||
vault=get_settings().vault_path; vault.mkdir(parents=True)
|
||||
Image.new('RGB',(100,50),'green').save(vault/'figure.png')
|
||||
async def run():
|
||||
job=await exports.create_export(ExportRequest(source={'type':'markdown','file_path':'demo.md',
|
||||
'markdown':'Formula $\\frac{x^2}{2}$\n\n'},format=format))
|
||||
done=await exports.wait_for_export(job.job_id)
|
||||
assert done.status.value=='completed'
|
||||
assert not any('公式' in w or '图片' in w for w in done.warnings)
|
||||
data=exports.get_export_file(job.job_id).read_bytes()
|
||||
if format=='html': assert data.count(b'data:image/png;base64,')==2
|
||||
if format=='docx':
|
||||
with ZipFile(BytesIO(data)) as archive: assert len([n for n in archive.namelist() if n.startswith('word/media/')])==2
|
||||
asyncio.run(run())
|
||||
|
||||
def test_local_image_path_escape_and_tex_fallback():
|
||||
from app.export.assets import enrich_document
|
||||
from app.export.markdown import parse_document
|
||||
document=parse_document('\n\n$\\unknownmacro{x}$')
|
||||
warnings=enrich_document(document,'demo.md')
|
||||
assert len(warnings)==2
|
||||
|
||||
@pytest.mark.parametrize('theme',['light','dark','sepia','paper-moments','ocean-blue','midnight-purple'])
|
||||
def test_function_preview_theme_and_parser(theme):
|
||||
from app.plot_routes import PlotRequest, preview
|
||||
result=preview(PlotRequest(source='y = x^2\ny = sin(x)',theme_id=theme))
|
||||
assert '<polyline' in result.result.content
|
||||
assert 'nan' not in result.result.content
|
||||
assert preview(PlotRequest(source='y = __import__("os")')).result is None
|
||||
|
||||
def test_agent_benchmark_real_runtime_offline_lifecycle():
|
||||
from app.config import get_settings
|
||||
from app.benchmarks import agent, service
|
||||
from app.container import container
|
||||
directory=get_settings().benchmark_datasets_path; directory.mkdir(parents=True,exist_ok=True)
|
||||
(directory/'agent-test.json').write_text(json.dumps({'dataset_id':'agent-test','kind':'agent','version':'1', 'cases':[
|
||||
{'case_id':'hello','prompt':'hello','output_contains':['definitely-absent'],'allowed_tools':[]}
|
||||
]}),encoding='utf-8')
|
||||
async def run():
|
||||
request=AgentBenchmarkRequest(dataset_id='agent-test',provider_id='mock',model='mock-model',offline=True)
|
||||
with pytest.raises(ApiError): await agent.create_run(request.model_copy(update={'offline':False}))
|
||||
created=await agent.create_run(request)
|
||||
done=await service.wait_for_run(created.run_id)
|
||||
assert done.status==BenchmarkStatus.completed
|
||||
report=service.get_report(created.run_id)
|
||||
assert report.metrics['task_success_rate']==0
|
||||
case=report.cases[0]
|
||||
assert case.agent_run_id and container.agent.get_run(case.agent_run_id)
|
||||
assert report.config_snapshot['execution']=='offline'
|
||||
events=service.get_events(created.run_id)
|
||||
assert [e.sequence for e in events]==list(range(len(events)))
|
||||
assert sum(e.event.value.startswith('Run') and e.event.value!='RunStarted' for e in events)==1
|
||||
second=await agent.create_run(request); service.cancel_run(second.run_id)
|
||||
assert (await service.wait_for_run(second.run_id)).status==BenchmarkStatus.cancelled
|
||||
asyncio.run(run())
|
||||
|
||||
def test_agent_score_counts_duplicate_and_invalid_calls():
|
||||
from types import SimpleNamespace as NS
|
||||
from app.contracts import AgentDatasetCase
|
||||
from app.benchmarks.agent import score,aggregate
|
||||
case=AgentDatasetCase(case_id='x',prompt='x',allowed_tools=['math.add'],expected_tools=[{'name':'math.add','arguments':{'left':2}}])
|
||||
events=[NS(event=NS(value='ToolCall'),data={'name':'math.add','arguments':{'left':2}}) for _ in range(2)]
|
||||
run=NS(status=NS(value='completed'),tool_results=[NS(success=False,name='math.add',error_code='TOOL_ARGUMENT_INVALID')],output='',citations=[],run_id='r',current_step=2,token_usage=10,error_code=None)
|
||||
result=score(case,run,events,10,0)
|
||||
assert not result.success
|
||||
assert aggregate([result])['tool_argument_accuracy']==.5
|
||||
assert aggregate([result])['invalid_tool_call_rate']==.5
|
||||
|
||||
def test_local_embedding_cache_is_config_scoped_and_returns_copies(monkeypatch,tmp_path):
|
||||
from app.local_models import runtime as local
|
||||
from app.retrieval.provenance import capture_embedding
|
||||
monkeypatch.setattr(local,'read_state',lambda key:{'status':'installed'})
|
||||
monkeypatch.setattr(local,'interpreter',lambda config=None:Path(__file__))
|
||||
monkeypatch.setattr(local,'model_path',lambda key:tmp_path/key)
|
||||
calls=[]
|
||||
async def infer(*args,**kwargs):
|
||||
calls.append(args);return [[.5]*384]
|
||||
monkeypatch.setattr(local.runtime,'infer',infer)
|
||||
async def run():
|
||||
embedding=local.LocalEmbedding(local.RuntimeConfig())
|
||||
first=await embedding.embed_documents(['query'])
|
||||
first[0][0]=999
|
||||
with capture_embedding() as observation:
|
||||
second=await embedding.embed_documents(['query'])
|
||||
assert second[0][0]==.5 and observation['query_embedding_cache']=='hit'
|
||||
assert len(calls)==1
|
||||
await local.LocalEmbedding(local.RuntimeConfig(version=2)).embed_documents(['query'])
|
||||
assert len(calls)==2
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_preview_http_and_agent_benchmark_validation():
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
with TestClient(app) as client:
|
||||
response = client.post('/api/plots/function', json={'source':'y = sin(x)', 'theme_id':'dark'})
|
||||
assert response.status_code == 200 and '<polyline' in response.json()['result']['content']
|
||||
assert client.post('/api/plots/function', json={'source':'x'*20001}).status_code == 422
|
||||
bad = client.post('/api/benchmarks/agent/runs', json={
|
||||
'dataset_id':'missing', 'provider_id':'missing', 'model':'missing'})
|
||||
assert bad.status_code == 404
|
||||
assert client.get('/api/benchmarks/runs/missing/report').status_code == 404
|
||||
schema = client.get('/openapi.json').json()
|
||||
assert '/api/benchmarks/agent/runs' in schema['paths']
|
||||
|
||||
|
||||
def test_preview_rejects_aggregate_complexity_before_sampling():
|
||||
from app.plot_routes import PlotRequest, preview
|
||||
source = '\n'.join('y = '+ '+'.join(['(x+x)']*150) for _ in range(16))
|
||||
result = preview(PlotRequest(source=source))
|
||||
assert result.result is None
|
||||
assert result.diagnostics[0].code == 'PLOT_BUDGET_EXCEEDED'
|
||||
|
||||
|
||||
def test_repeated_static_assets_share_document_resource_budget():
|
||||
from app.export.assets import attach_assets, enrich_document
|
||||
from app.export.markdown import parse_document
|
||||
document = parse_document(('```mermaid\nflowchart LR\n A --> B\n```\n\n')*65)
|
||||
attach_assets(document, validate_assets([asset()]))
|
||||
warnings = enrich_document(document)
|
||||
assert sum(bool(node.attributes.get('static_png')) for node in document.children) == 64
|
||||
assert any('预算' in warning for warning in warnings)
|
||||
@@ -107,7 +107,7 @@ def test_render_svg_contains_polyline_and_axes() -> None:
|
||||
assert "<line" in svg # 坐标轴/网格
|
||||
assert "<script" not in svg
|
||||
assert rendered.width == 640
|
||||
assert rendered.height == 480
|
||||
assert rendered.height == 504 # Includes the legend row.
|
||||
|
||||
|
||||
def test_render_svg_multiple_functions() -> None:
|
||||
@@ -300,7 +300,7 @@ def test_function_plot_static_renderer_renders_svg() -> None:
|
||||
assert "<polyline" in result.content
|
||||
assert result.mime_type == "image/svg+xml"
|
||||
assert result.width == 640
|
||||
assert result.height == 480
|
||||
assert result.height == 504 # Includes the legend row.
|
||||
|
||||
|
||||
def test_function_plot_static_renderer_parse_exposes_node_count() -> None:
|
||||
@@ -359,7 +359,8 @@ def test_render_reportlab_builds_drawing() -> None:
|
||||
kinds = {type(c).__name__ for c in drawing.contents}
|
||||
assert {"Line", "PolyLine", "String", "Group"} <= kinds
|
||||
strings = [c for c in drawing.contents if isinstance(c, String)]
|
||||
assert any(s.fontName == "STSong-Light" for s in strings)
|
||||
from app.export.fonts import FONT
|
||||
assert any(s.fontName == FONT for s in strings)
|
||||
assert any(s.text == "时间" for s in strings)
|
||||
# ylabel 在旋转 Group 内
|
||||
groups = [c for c in drawing.contents if isinstance(c, Group)]
|
||||
|
||||
Reference in New Issue
Block a user