mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-21 18:32:23 +03:00
feat(mcp): add drawing_create_text tool with Web UI compatible SVG assembly
The Web UI re-parses drawing SVG into its own element model instead of rendering it raw, which makes hand-assembled SVG error-prone for LLM callers. The new tool assembles the SVG server-side, mirroring what the Web UI itself serializes for text drawings: - XML-escaped text content, validated color (#RGB/#RGBA/#RRGGBB/ #RRGGBBAA) and font family (attribute-safe charset) - multi-line support: literal newlines render as stacked lines in the Web UI (tspan split), width/height computed from the longest line - pt-aware sizing: the Web UI renders font-size in pt (1pt = 4/3 px), so width uses a 0.8/char factor for monospace (1.0 proportional) and line height 1.9 x font_size, instead of the px-based 0.62 estimate - ET.fromstring pre-check so bad input fails loudly instead of hitting the controller's silent drop of unparseable 500+ char SVG - computed width/height/line_height returned for label layout Also correct the raw drawing_create docstring: root width/height are required (the parser reads them for the selection box), x/y on rect are ignored, <g> is unsupported, <path> is re-interpreted as a freehand curve, and the old examples lacked the mandatory root dimensions.
This commit is contained in:
parent
1e0d123f4b
commit
73cbb0387c
@ -110,6 +110,7 @@ from .device_config import (
|
|||||||
)
|
)
|
||||||
from .drawings import (
|
from .drawings import (
|
||||||
create_drawing_handler,
|
create_drawing_handler,
|
||||||
|
create_text_drawing_handler,
|
||||||
delete_drawing_handler,
|
delete_drawing_handler,
|
||||||
get_drawing_handler,
|
get_drawing_handler,
|
||||||
get_drawings_handler,
|
get_drawings_handler,
|
||||||
@ -1548,20 +1549,24 @@ async def drawing_create(
|
|||||||
locked: Annotated[bool, Field(description="Lock the drawing (default: false)")] = False,
|
locked: Annotated[bool, Field(description="Lock the drawing (default: false)")] = False,
|
||||||
rotation: Annotated[int, Field(description="Rotation angle in degrees, -359 to 359 (default: 0)")] = 0,
|
rotation: Annotated[int, Field(description="Rotation angle in degrees, -359 to 359 (default: 0)")] = 0,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Create a new drawing (label, shape, or image) on a project canvas.
|
"""Create a new drawing (label, shape, or image) on a project canvas from raw SVG.
|
||||||
|
|
||||||
GNS3 SVG rendering notes:
|
Web UI compatibility rules (it re-parses the SVG instead of rendering it raw):
|
||||||
- <rect> MUST have a solid fill color (e.g. fill=\"#FF0000\") to render.
|
- Root must be <svg width="W" height="H">; W/H set the selection box size and are required.
|
||||||
fill=\"none\" or fill=\"transparent\" will be invisible in the GUI.
|
- Exactly ONE child element, from: text, image, rect, line, ellipse, path.
|
||||||
- <ellipse> works correctly with or without fill.
|
<g> is not supported; extra children are dropped (first recognized child wins).
|
||||||
- <line> and <text> work normally.
|
- <path> is re-drawn as a freehand curve from the points in d — not a faithful render.
|
||||||
|
- <rect>: omit x/y (ignored, the rect sits at the drawing origin) and give a solid fill.
|
||||||
|
- <ellipse>: cx/cy are offsets inside the drawing box, not canvas coordinates.
|
||||||
|
- font-size is displayed in pt units by the Web UI (1pt = 4/3 px).
|
||||||
|
|
||||||
|
For text labels use drawing_create_text instead — it handles escaping and sizing.
|
||||||
|
|
||||||
SVG examples:
|
SVG examples:
|
||||||
Text label: <svg><text x=\"10\" y=\"20\" font-size=\"14\">R1</text></svg>
|
Rectangle: <svg width="80" height="50"><rect width="80" height="50" fill="#4A90D9"/></svg>
|
||||||
Rectangle: <svg><rect x=\"10\" y=\"10\" width=\"80\" height=\"50\" fill=\"#4A90D9\" stroke=\"black\"/></svg>
|
Ellipse: <svg width="80" height="40"><ellipse cx="40" cy="20" rx="40" ry="20" fill="red"/></svg>
|
||||||
Ellipse: <svg><ellipse cx=\"50\" cy=\"50\" rx=\"40\" ry=\"20\" fill=\"red\" stroke=\"black\"/></svg>
|
Line: <svg width="100" height="100"><line x2="100" y2="100" stroke="black" stroke-width="2"/></svg>
|
||||||
Line: <svg><line x1=\"0\" y1=\"0\" x2=\"100\" y2=\"100\" stroke=\"black\" stroke-width=\"2\"/></svg>
|
Dashed line: <svg width="100" height="100"><line x2="100" y2="100" stroke="black" stroke-dasharray="5,5"/></svg>
|
||||||
Dashed line: <svg><line x1=\"0\" y1=\"0\" x2=\"100\" y2=\"100\" stroke=\"black\" stroke-dasharray=\"5,5\"/></svg>
|
|
||||||
"""
|
"""
|
||||||
return await asyncio.to_thread(
|
return await asyncio.to_thread(
|
||||||
_run_handler_sync,
|
_run_handler_sync,
|
||||||
@ -1578,6 +1583,50 @@ async def drawing_create(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def drawing_create_text(
|
||||||
|
project_id: Annotated[str, Field(description="Project name or UUID")],
|
||||||
|
x: Annotated[int, Field(description="X coordinate of the text top-left corner on the canvas")],
|
||||||
|
y: Annotated[int, Field(description="Y coordinate of the text top-left corner on the canvas")],
|
||||||
|
text: Annotated[str, Field(description="Label text; newlines (\\n) start additional lines")],
|
||||||
|
font_size: Annotated[int, Field(description="Font size, 1-200 (default: 13)")] = 13,
|
||||||
|
color: Annotated[str, Field(description="Text color, hex like #RRGGBB (default: #000000)")] = "#000000",
|
||||||
|
bold: Annotated[bool, Field(description="Bold text (default: false)")] = False,
|
||||||
|
font_family: Annotated[str, Field(description="Font family name (default: monospace)")] = "monospace",
|
||||||
|
z: Annotated[int, Field(description="Z layer; 1 keeps text above links (default: 1)")] = 1,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Add a text label to a project canvas.
|
||||||
|
|
||||||
|
The SVG is assembled server-side in the Web UI compatible format; prefer this
|
||||||
|
over hand-writing SVG for drawing_create.
|
||||||
|
|
||||||
|
- XML characters (& < >) are escaped automatically.
|
||||||
|
- Multi-line: embed newlines (\\n) in text; each line renders below the previous one.
|
||||||
|
- The drawing width/height are computed from the text and returned, so labels
|
||||||
|
can be laid out without guessing the selection box size.
|
||||||
|
- Constraints: color must be hex like #RRGGBB; font_family accepts plain names
|
||||||
|
(letters, digits, spaces, . ' -); text capped at 500 characters.
|
||||||
|
|
||||||
|
Example: bold red label at (100, 80):
|
||||||
|
drawing_create_text(project_id="demo", x=100, y=80, text="R1 management", color="#CC0000", bold=True)
|
||||||
|
"""
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_run_handler_sync,
|
||||||
|
create_text_drawing_handler,
|
||||||
|
{
|
||||||
|
"project_id": project_id,
|
||||||
|
"text": text,
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"z": z,
|
||||||
|
"font_size": font_size,
|
||||||
|
"color": color,
|
||||||
|
"bold": bold,
|
||||||
|
"font_family": font_family,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def drawing_get(
|
async def drawing_get(
|
||||||
project_id: Annotated[str, Field(description="Project name or UUID")],
|
project_id: Annotated[str, Field(description="Project name or UUID")],
|
||||||
|
|||||||
@ -20,7 +20,11 @@ MCP tool handlers for GNS3 drawing management.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
from math import ceil
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
from xml.sax.saxutils import escape
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -69,6 +73,101 @@ def create_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
|
|||||||
return {"message": "Drawing created", "drawing": result}
|
return {"message": "Drawing created", "drawing": result}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Text drawing ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# The Web UI renders font-size as pt (1pt = 4/3 px) and monospace glyphs advance
|
||||||
|
# ~0.6em, so one character is ~0.8 x font_size in canvas pixels. Proportional
|
||||||
|
# fonts get a wider worst-case factor so the selection box does not clip the text.
|
||||||
|
_MONOSPACE_CHAR_FACTOR = 0.8
|
||||||
|
_PROPORTIONAL_CHAR_FACTOR = 1.0
|
||||||
|
# The Web UI stacks lines at dy=1.4em, i.e. 1.4 x 4/3 ~ 1.87 px per pt of font size.
|
||||||
|
_LINE_HEIGHT_FACTOR = 1.9
|
||||||
|
_MAX_TEXT_LENGTH = 500
|
||||||
|
|
||||||
|
_COLOR_RE = re.compile(r"^#(?:[0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$")
|
||||||
|
_FONT_FAMILY_RE = re.compile(r"^[A-Za-z0-9 ,.'-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def build_text_svg(
|
||||||
|
text: str, font_size: int = 13, color: str = "#000000", bold: bool = False, font_family: str = "monospace"
|
||||||
|
) -> tuple[str, int, int, int]:
|
||||||
|
"""
|
||||||
|
Build a Web UI compatible single <text> SVG for a canvas label.
|
||||||
|
|
||||||
|
The output mirrors what the Web UI itself serializes for text drawings:
|
||||||
|
root <svg height width> with one <text> child and no x/y (the Web UI parser
|
||||||
|
ignores them and positions the text itself).
|
||||||
|
|
||||||
|
:returns: (svg, width, height, line_height) with the computed drawing size
|
||||||
|
"""
|
||||||
|
|
||||||
|
lines = text.split("\n")
|
||||||
|
factor = _MONOSPACE_CHAR_FACTOR if "monospace" in font_family.lower() else _PROPORTIONAL_CHAR_FACTOR
|
||||||
|
width = max(1, ceil(max(len(line) for line in lines) * font_size * factor))
|
||||||
|
line_height = ceil(font_size * _LINE_HEIGHT_FACTOR)
|
||||||
|
height = len(lines) * line_height
|
||||||
|
weight = "bold" if bold else "normal"
|
||||||
|
svg = (
|
||||||
|
f'<svg height="{height}" width="{width}">'
|
||||||
|
f'<text fill="{color}" fill-opacity="1.0" '
|
||||||
|
f'font-family="{font_family}" font-size="{font_size}" '
|
||||||
|
f'font-weight="{weight}">{escape(text)}</text></svg>'
|
||||||
|
)
|
||||||
|
# Fail loudly here: the controller silently keeps the previous (empty) SVG
|
||||||
|
# when handed an unparseable string of 500+ characters.
|
||||||
|
ET.fromstring(svg)
|
||||||
|
return svg, width, height, line_height
|
||||||
|
|
||||||
|
|
||||||
|
def create_text_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
project_id = params.get("project_id")
|
||||||
|
text = params.get("text")
|
||||||
|
if not project_id:
|
||||||
|
return {"error": "project_id is required"}
|
||||||
|
if not text:
|
||||||
|
return {"error": "text is required"}
|
||||||
|
text = text.replace("\r\n", "\n").replace("\r", "\n").strip("\n")
|
||||||
|
if not text:
|
||||||
|
return {"error": "text is required"}
|
||||||
|
if len(text) > _MAX_TEXT_LENGTH:
|
||||||
|
return {"error": f"text is too long (max {_MAX_TEXT_LENGTH} characters)"}
|
||||||
|
if any(ord(ch) < 32 and ch != "\n" for ch in text) or "\x7f" in text:
|
||||||
|
return {"error": "text contains control characters (only newlines are allowed)"}
|
||||||
|
|
||||||
|
font_size = params.get("font_size", 13)
|
||||||
|
if isinstance(font_size, bool) or not isinstance(font_size, int) or not 1 <= font_size <= 200:
|
||||||
|
return {"error": "font_size must be an integer between 1 and 200"}
|
||||||
|
color = params.get("color", "#000000")
|
||||||
|
if not _COLOR_RE.match(color):
|
||||||
|
return {"error": "color must be a hex value like #RRGGBB"}
|
||||||
|
font_family = params.get("font_family", "monospace")
|
||||||
|
if not _FONT_FAMILY_RE.match(font_family):
|
||||||
|
return {"error": "font_family contains invalid characters (allowed: letters, digits, spaces, . ' -)"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
svg, width, height, line_height = build_text_svg(
|
||||||
|
text, font_size=font_size, color=color, bold=params.get("bold", False), font_family=font_family
|
||||||
|
)
|
||||||
|
except ET.ParseError as e:
|
||||||
|
return {"error": f"generated SVG is not valid XML: {e}"}
|
||||||
|
|
||||||
|
conn = _get_connector(gns3_ctx)
|
||||||
|
data = {
|
||||||
|
"svg": svg,
|
||||||
|
"x": params.get("x", 0),
|
||||||
|
"y": params.get("y", 0),
|
||||||
|
"z": params.get("z", 1),
|
||||||
|
}
|
||||||
|
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json()
|
||||||
|
return {
|
||||||
|
"message": "Text drawing created",
|
||||||
|
"drawing": result,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"line_height": line_height,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
def get_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||||
project_id = params.get("project_id")
|
project_id = params.get("project_id")
|
||||||
drawing_id = params.get("drawing_id")
|
drawing_id = params.get("drawing_id")
|
||||||
|
|||||||
@ -165,6 +165,8 @@ extend-immutable-calls = [
|
|||||||
"gns3server/agent/gns3_copilot/utils/parse_tool_content.py" = ["C901", "DTZ003"]
|
"gns3server/agent/gns3_copilot/utils/parse_tool_content.py" = ["C901", "DTZ003"]
|
||||||
"gns3server/agent/mcp/__init__.py" = ["RUF003"]
|
"gns3server/agent/mcp/__init__.py" = ["RUF003"]
|
||||||
"gns3server/agent/mcp/device_config.py" = ["E741"]
|
"gns3server/agent/mcp/device_config.py" = ["E741"]
|
||||||
|
# Parses self-generated SVG (validated inputs) like controller/drawing.py does.
|
||||||
|
"gns3server/agent/mcp/drawings.py" = ["S314"]
|
||||||
"gns3server/agent/web_wireshark/manager.py" = ["C901"]
|
"gns3server/agent/web_wireshark/manager.py" = ["C901"]
|
||||||
"gns3server/agent/web_wireshark/setup_wireshark_image.py" = ["PLW1510", "S603", "S607"]
|
"gns3server/agent/web_wireshark/setup_wireshark_image.py" = ["PLW1510", "S603", "S607"]
|
||||||
"gns3server/agent/web_wireshark/stats.py" = ["ASYNC221", "PLW1510", "S603", "S607"]
|
"gns3server/agent/web_wireshark/stats.py" = ["ASYNC221", "PLW1510", "S603", "S607"]
|
||||||
@ -234,6 +236,7 @@ extend-immutable-calls = [
|
|||||||
"scripts/extract_mermaid.py" = ["C901", "PLW1510", "S603", "S607"]
|
"scripts/extract_mermaid.py" = ["C901", "PLW1510", "S603", "S607"]
|
||||||
"scripts/tag.py" = ["S603", "S607"]
|
"scripts/tag.py" = ["S603", "S607"]
|
||||||
"scripts/welcome.py" = ["C901", "E722", "PLW1510", "S108", "S112", "S603", "S605", "S606", "S607"]
|
"scripts/welcome.py" = ["C901", "E722", "PLW1510", "S108", "S112", "S603", "S605", "S606", "S607"]
|
||||||
|
"tests/agent/mcp/test_drawings.py" = ["S314"]
|
||||||
"tests/agent/mcp/test_handlers.py" = ["B905"]
|
"tests/agent/mcp/test_handlers.py" = ["B905"]
|
||||||
"tests/agent/mcp/test_tool_params.py" = ["C901"]
|
"tests/agent/mcp/test_tool_params.py" = ["C901"]
|
||||||
"tests/api/test_openapi_spec.py" = ["S603"]
|
"tests/api/test_openapi_spec.py" = ["S603"]
|
||||||
|
|||||||
149
tests/agent/mcp/test_drawings.py
Normal file
149
tests/agent/mcp/test_drawings.py
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
"""
|
||||||
|
MCP text drawing tests: SVG builder units and handler behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gns3server.agent.mcp.drawings import build_text_svg, create_text_drawing_handler
|
||||||
|
|
||||||
|
BASE = "gns3server.agent.mcp"
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_conn(json_result=None):
|
||||||
|
"""Create a mocked Gns3Connector with base_url and http_call."""
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.base_url = "http://192.168.1.3:3080/v3"
|
||||||
|
conn.http_call.return_value.json.return_value = json_result or {"status": "ok"}
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ctx():
|
||||||
|
return {"server_url": "http://192.168.1.3:3080", "jwt_token": "token", "jwt_username": "admin"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildTextSvg:
|
||||||
|
def test_single_line(self):
|
||||||
|
svg, width, height, line_height = build_text_svg("R1", font_size=13)
|
||||||
|
assert svg == (
|
||||||
|
'<svg height="25" width="21">'
|
||||||
|
'<text fill="#000000" fill-opacity="1.0" font-family="monospace" '
|
||||||
|
'font-size="13" font-weight="normal">R1</text></svg>'
|
||||||
|
)
|
||||||
|
# 2 chars x 13 x 0.8 = 20.8 -> 21; one line of 13 x 1.9 = 24.7 -> 25
|
||||||
|
assert width == 21
|
||||||
|
assert height == 25
|
||||||
|
assert line_height == 25
|
||||||
|
|
||||||
|
def test_bold_and_color(self):
|
||||||
|
svg, _, _, _ = build_text_svg("R1", font_size=13, color="#CC0000", bold=True)
|
||||||
|
assert 'fill="#CC0000"' in svg
|
||||||
|
assert 'font-weight="bold"' in svg
|
||||||
|
|
||||||
|
def test_escapes_xml_characters(self):
|
||||||
|
svg, _, _, _ = build_text_svg("a<b&c", font_size=10)
|
||||||
|
root = ET.fromstring(svg)
|
||||||
|
# round-trips through the parser back to the original text
|
||||||
|
assert root.find("text").text == "a<b&c"
|
||||||
|
assert "a<b&c" in svg
|
||||||
|
|
||||||
|
def test_multi_line(self):
|
||||||
|
svg, width, height, line_height = build_text_svg("hello\nworld!", font_size=10)
|
||||||
|
# longest line is 6 chars x 10 x 0.8 = 48; two lines of ceil(19)
|
||||||
|
assert width == 48
|
||||||
|
assert height == 38
|
||||||
|
assert line_height == 19
|
||||||
|
assert "\n" in ET.fromstring(svg).find("text").text
|
||||||
|
|
||||||
|
def test_proportional_font_wider(self):
|
||||||
|
mono_w, _, _, _ = build_text_svg("same", font_size=10, font_family="monospace")
|
||||||
|
prop_w, _, _, _ = build_text_svg("same", font_size=10, font_family="Noto Sans")
|
||||||
|
assert prop_w > mono_w
|
||||||
|
|
||||||
|
def test_empty_line_kept(self):
|
||||||
|
# a blank line still occupies a line box
|
||||||
|
_, _, height, line_height = build_text_svg("a\n\nb", font_size=10)
|
||||||
|
assert height == 3 * line_height
|
||||||
|
|
||||||
|
def test_matches_web_ui_serializer_shape(self):
|
||||||
|
# byte-for-byte structural parity with the Web UI's own text output
|
||||||
|
svg, width, height, _ = build_text_svg("demo", font_size=11)
|
||||||
|
assert svg.startswith(f'<svg height="{height}" width="{width}"><text fill=')
|
||||||
|
assert svg.endswith("</text></svg>")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateTextDrawingHandler:
|
||||||
|
def test_success(self, ctx):
|
||||||
|
with patch(f"{BASE}.drawings._get_connector") as m:
|
||||||
|
conn = _mock_conn({"drawing_id": "d1", "x": 100, "y": 80})
|
||||||
|
m.return_value = conn
|
||||||
|
result = create_text_drawing_handler(
|
||||||
|
{"project_id": "p1", "text": "R1", "x": 100, "y": 80, "bold": True}, ctx
|
||||||
|
)
|
||||||
|
assert result["drawing"]["drawing_id"] == "d1"
|
||||||
|
assert result["width"] == 21
|
||||||
|
assert result["height"] == 25
|
||||||
|
# z defaults to 1 (above links)
|
||||||
|
body = conn.http_call.call_args.kwargs["json_data"]
|
||||||
|
assert body["z"] == 1
|
||||||
|
assert body["x"] == 100
|
||||||
|
assert 'font-weight="bold"' in body["svg"]
|
||||||
|
|
||||||
|
def test_missing_project_id(self, ctx):
|
||||||
|
assert "error" in create_text_drawing_handler({"text": "R1"}, ctx)
|
||||||
|
|
||||||
|
def test_missing_text(self, ctx):
|
||||||
|
assert "error" in create_text_drawing_handler({"project_id": "p1"}, ctx)
|
||||||
|
|
||||||
|
def test_blank_text(self, ctx):
|
||||||
|
assert "error" in create_text_drawing_handler({"project_id": "p1", "text": "\n\n"}, ctx)
|
||||||
|
|
||||||
|
def test_newline_normalization(self, ctx):
|
||||||
|
with patch(f"{BASE}.drawings._get_connector") as m:
|
||||||
|
conn = _mock_conn({"drawing_id": "d1"})
|
||||||
|
m.return_value = conn
|
||||||
|
create_text_drawing_handler({"project_id": "p1", "text": "a\r\nb"}, ctx)
|
||||||
|
svg = conn.http_call.call_args.kwargs["json_data"]["svg"]
|
||||||
|
assert "\r" not in svg
|
||||||
|
|
||||||
|
def test_text_too_long(self, ctx):
|
||||||
|
assert "error" in create_text_drawing_handler({"project_id": "p1", "text": "x" * 501}, ctx)
|
||||||
|
|
||||||
|
def test_control_characters_rejected(self, ctx):
|
||||||
|
assert "error" in create_text_drawing_handler({"project_id": "p1", "text": "a\x00b"}, ctx)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("font_size", [0, 201, "13", 13.5, True])
|
||||||
|
def test_bad_font_size(self, ctx, font_size):
|
||||||
|
result = create_text_drawing_handler({"project_id": "p1", "text": "R1", "font_size": font_size}, ctx)
|
||||||
|
assert "error" in result
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("color", ["red", "123456", "#GGGGGG", "#12345", "black\" onload=\"x"])
|
||||||
|
def test_bad_color(self, ctx, color):
|
||||||
|
result = create_text_drawing_handler({"project_id": "p1", "text": "R1", "color": color}, ctx)
|
||||||
|
assert "error" in result
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("font_family", ['mono" onload="x', "sans<serif>", "a&b", ""])
|
||||||
|
def test_bad_font_family(self, ctx, font_family):
|
||||||
|
result = create_text_drawing_handler({"project_id": "p1", "text": "R1", "font_family": font_family}, ctx)
|
||||||
|
assert "error" in result
|
||||||
|
|
||||||
|
def test_font_family_with_spaces_accepted(self, ctx):
|
||||||
|
with patch(f"{BASE}.drawings._get_connector") as m:
|
||||||
|
conn = _mock_conn({"drawing_id": "d1"})
|
||||||
|
m.return_value = conn
|
||||||
|
result = create_text_drawing_handler(
|
||||||
|
{"project_id": "p1", "text": "R1", "font_family": "DejaVu Sans Mono"}, ctx
|
||||||
|
)
|
||||||
|
assert "error" not in result
|
||||||
|
|
||||||
|
def test_xml_entities_survive_round_trip(self, ctx):
|
||||||
|
with patch(f"{BASE}.drawings._get_connector") as m:
|
||||||
|
conn = _mock_conn({"drawing_id": "d1"})
|
||||||
|
m.return_value = conn
|
||||||
|
create_text_drawing_handler({"project_id": "p1", "text": "R1 & <backup>"}, ctx)
|
||||||
|
svg = conn.http_call.call_args.kwargs["json_data"]["svg"]
|
||||||
|
assert "R1 & <backup>" in svg
|
||||||
|
ET.fromstring(svg)
|
||||||
@ -81,6 +81,7 @@ HANDLER_FILES = {
|
|||||||
"restore_snapshot_handler": "snapshots.py",
|
"restore_snapshot_handler": "snapshots.py",
|
||||||
"get_drawings_handler": "drawings.py",
|
"get_drawings_handler": "drawings.py",
|
||||||
"create_drawing_handler": "drawings.py",
|
"create_drawing_handler": "drawings.py",
|
||||||
|
"create_text_drawing_handler": "drawings.py",
|
||||||
"get_drawing_handler": "drawings.py",
|
"get_drawing_handler": "drawings.py",
|
||||||
"update_drawing_handler": "drawings.py",
|
"update_drawing_handler": "drawings.py",
|
||||||
"delete_drawing_handler": "drawings.py",
|
"delete_drawing_handler": "drawings.py",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user