- 新增 app/plot 包:白名单表达式解析(ast 无 eval)+ FunctionPlot 模型 + 静态 SVG 渲染 - HtmlExporter 的 function_plot 节点解析并内嵌 SVG,解析失败回退占位并转诊断 - 新增 test_plot.py(13 个测试)覆盖表达式安全、指令解析、SVG 输出与导出链路集成 Co-Authored-By: Claude Code <noreply@anthropic.com>
140 lines
5.1 KiB
Python
140 lines
5.1 KiB
Python
"""Function Plot 的解析与静态 SVG 渲染测试。
|
|
|
|
覆盖 parser 的白名单表达式(幂/隐式乘法/函数/常量)、拒绝项(属性访问、任意调用等)、
|
|
parse_source 指令与回退,以及 render 的 SVG 输出与 HTML 导出链路集成。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import math
|
|
|
|
import pytest
|
|
|
|
from app.contracts import ExportOptions
|
|
from app.export.exporters.html import HtmlExporter
|
|
from app.export.markdown import parse_document
|
|
from app.plot.parser import PlotParseError, evaluate, parse_expression, parse_source
|
|
from app.plot.render import render_svg
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 表达式解析
|
|
# --------------------------------------------------------------------------- #
|
|
def test_parse_expression_power_and_implicit_multiplication() -> None:
|
|
assert evaluate(parse_expression("x^2"), 3) == 9.0
|
|
assert evaluate(parse_expression("2^3"), 0) == 8.0
|
|
assert evaluate(parse_expression("2x+1"), 3) == 7.0
|
|
assert evaluate(parse_expression("2(x+1)"), 3) == 8.0
|
|
assert evaluate(parse_expression("(x+1)(x-1)"), 3) == 8.0
|
|
|
|
|
|
def test_parse_expression_functions_and_constants() -> None:
|
|
assert evaluate(parse_expression("sin(0)"), 0) == 0.0
|
|
assert math.isclose(evaluate(parse_expression("sin(pi/2)"), 0), 1.0)
|
|
assert math.isclose(evaluate(parse_expression("ln(e)"), 0), 1.0)
|
|
assert evaluate(parse_expression("abs(-3)"), 0) == 3.0
|
|
|
|
|
|
def test_parse_expression_rejects_unsafe() -> None:
|
|
unsafe = [
|
|
"os.system('x')",
|
|
"__import__('os')",
|
|
"foo(x)",
|
|
"eval('x')",
|
|
"x[0]",
|
|
"x.attr",
|
|
"lambda: 1",
|
|
]
|
|
for expr in unsafe:
|
|
with pytest.raises(PlotParseError) as exc:
|
|
parse_expression(expr)
|
|
assert exc.value.diagnostic.code == "FUNCTION_PLOT_EXPRESSION_UNSAFE", expr
|
|
|
|
|
|
def test_parse_expression_syntax_error() -> None:
|
|
with pytest.raises(PlotParseError) as exc:
|
|
parse_expression("x +")
|
|
assert exc.value.diagnostic.code == "FUNCTION_PLOT_PARSE_FAILED"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# fenced 源码解析
|
|
# --------------------------------------------------------------------------- #
|
|
def test_parse_source_directives() -> None:
|
|
result = parse_source("domain: 0, 10\nrange: -1, 1\nxlabel: x\ngrid: false\ny = x^2")
|
|
assert result.plot is not None
|
|
assert result.plot.domain == (0.0, 10.0)
|
|
assert result.plot.range == (-1.0, 1.0)
|
|
assert result.plot.axes.xlabel == "x"
|
|
assert result.plot.axes.grid is False
|
|
assert len(result.plot.expressions) == 1
|
|
assert result.plot.expressions[0].expression == "x^2"
|
|
|
|
|
|
def test_parse_source_bare_and_multi_expression() -> None:
|
|
result = parse_source("x^2\nsin(x)")
|
|
assert result.plot is not None
|
|
assert [e.expression for e in result.plot.expressions] == ["x^2", "sin(x)"]
|
|
|
|
|
|
def test_parse_source_unknown_directive_warns() -> None:
|
|
result = parse_source("foo: bar\ny = x")
|
|
assert result.plot is not None # 未知指令仅 warning,不阻断
|
|
assert any(d.severity == "warning" for d in result.diagnostics)
|
|
|
|
|
|
def test_parse_source_error_returns_no_plot() -> None:
|
|
result = parse_source("y = os.system('x')")
|
|
assert result.plot is None
|
|
assert any(d.severity == "error" for d in result.diagnostics)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# SVG 渲染
|
|
# --------------------------------------------------------------------------- #
|
|
def test_render_svg_contains_polyline_and_axes() -> None:
|
|
plot = parse_source("y = x^2").plot
|
|
rendered = render_svg(plot)
|
|
svg = rendered.content
|
|
assert "<svg" in svg
|
|
assert "<polyline" in svg
|
|
assert "<line" in svg # 坐标轴/网格
|
|
assert "<script" not in svg
|
|
assert rendered.width == 640
|
|
assert rendered.height == 480
|
|
|
|
|
|
def test_render_svg_multiple_functions() -> None:
|
|
plot = parse_source("y = x^2\ny = sin(x)").plot
|
|
rendered = render_svg(plot)
|
|
assert rendered.content.count("<polyline") >= 2
|
|
|
|
|
|
def test_render_svg_labels() -> None:
|
|
plot = parse_source("xlabel: 时间\nylabel: 数值\ny = x").plot
|
|
rendered = render_svg(plot)
|
|
assert "时间" in rendered.content
|
|
assert "数值" in rendered.content
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# HTML 导出链路集成
|
|
# --------------------------------------------------------------------------- #
|
|
def test_html_exporter_embeds_function_plot_svg() -> None:
|
|
md = "```function-plot\ny = x^2\n```"
|
|
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
|
|
html = result.content.decode("utf-8")
|
|
assert '<figure class="function-plot">' in html
|
|
assert "<svg" in html
|
|
assert "<polyline" in html
|
|
|
|
|
|
def test_html_exporter_function_plot_fallback_on_error() -> None:
|
|
md = "```function-plot\ny = os.system('x')\n```"
|
|
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
|
|
html = result.content.decode("utf-8")
|
|
assert '<pre class="function-plot">' in html
|
|
assert "<svg" not in html
|
|
assert any("函数图像" in w for w in result.warnings)
|