mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 05:51:15 +02:00
feat(agent): share Web UI compatible drawing builders between MCP and copilot
The text drawing tool was MCP-only; the copilot needs the same capability and its area-annotation helper carried the same Web UI compat bugs. Split into three layers with a single implementation: - gns3server/utils/drawings.py: pure SVG builders (text, rectangle, ellipse) with full input validation (ValueError) — the Web UI format knowledge lives in core utils next to picture.py - api_handlers.py: shared REST handlers (create_text_drawing_handler moved here, create_rectangle/ellipse_drawing_handler added) — the layer both consumers already share - thin shells: MCP tools (drawing_create_text unchanged, plus new drawing_create_rectangle/_ellipse) and three LangChain tools (tools_v2/gns3_create_drawing.py) registered in both copilot mode lists Shape builder details: - rectangle: solid fill or "none", stroke block emitted only when a stroke is given, rx corner radius, dasharray validated against the Web UI's Qt remap table (QtDasharrayFixer rewrites those patterns) - ellipse: bounding-box API; cx/cy derived as offsets inside the drawing box (the Web UI treats them as box-relative, not canvas coordinates); odd sizes round down and the actual size is returned gns3_drawing_utils now delegates to the shared builders, fixing the px-blind sizing, the ignored text-anchor/x/y centering and the missing XML escaping in the area-annotation path.
This commit is contained in:
13 files changed
+1003
-265
No files matched your search
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
MCP drawing handler tests: text/rectangle/ellipse creation via the shared
|
||||
api_handlers layer (SVG building units live in tests/utils/test_drawings.py).
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
create_ellipse_drawing_handler,
|
||||
create_rectangle_drawing_handler,
|
||||
create_text_drawing_handler,
|
||||
)
|
||||
|
||||
AH = "gns3server.agent.gns3_copilot.gns3_client.api_handlers"
|
||||
|
||||
|
||||
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 TestCreateTextDrawingHandler:
|
||||
def test_success(self, ctx):
|
||||
with patch(f"{AH}._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"{AH}._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
|
||||
|
||||
@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_xml_entities_survive_round_trip(self, ctx):
|
||||
with patch(f"{AH}._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)
|
||||
|
||||
|
||||
class TestCreateRectangleDrawingHandler:
|
||||
def test_success(self, ctx):
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn({"drawing_id": "d2"})
|
||||
m.return_value = conn
|
||||
result = create_rectangle_drawing_handler(
|
||||
{"project_id": "p1", "x": 200, "y": 150, "width": 80, "height": 50, "fill": "#4A90D9"}, ctx
|
||||
)
|
||||
assert result["drawing"]["drawing_id"] == "d2"
|
||||
assert result["width"] == 80
|
||||
assert result["height"] == 50
|
||||
body = conn.http_call.call_args.kwargs["json_data"]
|
||||
# z defaults to 0 (shapes behind text and nodes)
|
||||
assert body["z"] == 0
|
||||
assert body["svg"] == (
|
||||
'<svg height="50" width="80">'
|
||||
'<rect fill="#4A90D9" fill-opacity="1.0" height="50" width="80" rx="0" ry="0"/></svg>'
|
||||
)
|
||||
|
||||
def test_missing_dimensions(self, ctx):
|
||||
assert "error" in create_rectangle_drawing_handler({"project_id": "p1"}, ctx)
|
||||
|
||||
def test_bad_fill_returns_error(self, ctx):
|
||||
result = create_rectangle_drawing_handler({"project_id": "p1", "width": 80, "height": 50, "fill": "red"}, ctx)
|
||||
assert "error" in result
|
||||
|
||||
def test_qt_remapped_dasharray_rejected(self, ctx):
|
||||
result = create_rectangle_drawing_handler(
|
||||
{"project_id": "p1", "width": 80, "height": 50, "stroke": "#333333", "dasharray": "25, 25"}, ctx
|
||||
)
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestCreateEllipseDrawingHandler:
|
||||
def test_success(self, ctx):
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn({"drawing_id": "d3"})
|
||||
m.return_value = conn
|
||||
result = create_ellipse_drawing_handler(
|
||||
{"project_id": "p1", "x": 300, "y": 300, "width": 160, "height": 160, "fill": "#5AA9DD"}, ctx
|
||||
)
|
||||
assert result["drawing"]["drawing_id"] == "d3"
|
||||
body = conn.http_call.call_args.kwargs["json_data"]
|
||||
assert body["z"] == 0
|
||||
assert body["svg"] == (
|
||||
'<svg height="160" width="160">'
|
||||
'<ellipse fill="#5AA9DD" fill-opacity="1.0" cx="80" cy="80" rx="80" ry="80"/></svg>'
|
||||
)
|
||||
|
||||
def test_odd_dimensions_report_actual_size(self, ctx):
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn({"drawing_id": "d3"})
|
||||
m.return_value = conn
|
||||
result = create_ellipse_drawing_handler({"project_id": "p1", "width": 101, "height": 61}, ctx)
|
||||
assert (result["width"], result["height"]) == (100, 60)
|
||||
|
||||
def test_too_small(self, ctx):
|
||||
result = create_ellipse_drawing_handler({"project_id": "p1", "width": 1, "height": 60}, ctx)
|
||||
assert "error" in result
|
||||
|
||||
def test_missing_dimensions(self, ctx):
|
||||
assert "error" in create_ellipse_drawing_handler({"project_id": "p1", "width": 100}, ctx)
|
||||
@@ -1,149 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -67,6 +67,9 @@ HANDLER_FILES = {
|
||||
"download_capture_file_handler": API_HANDLERS_FILE,
|
||||
"link_marker_handler": API_HANDLERS_FILE,
|
||||
"marker_definition_handler": API_HANDLERS_FILE,
|
||||
"create_text_drawing_handler": API_HANDLERS_FILE,
|
||||
"create_rectangle_drawing_handler": API_HANDLERS_FILE,
|
||||
"create_ellipse_drawing_handler": API_HANDLERS_FILE,
|
||||
"list_templates_handler": "templates.py",
|
||||
"get_template_handler": "templates.py",
|
||||
"create_template_handler": "templates.py",
|
||||
@@ -81,7 +84,6 @@ HANDLER_FILES = {
|
||||
"restore_snapshot_handler": "snapshots.py",
|
||||
"get_drawings_handler": "drawings.py",
|
||||
"create_drawing_handler": "drawings.py",
|
||||
"create_text_drawing_handler": "drawings.py",
|
||||
"get_drawing_handler": "drawings.py",
|
||||
"update_drawing_handler": "drawings.py",
|
||||
"delete_drawing_handler": "drawings.py",
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Web UI compatible drawing SVG builder tests.
|
||||
"""
|
||||
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
from gns3server.utils.drawings import build_ellipse_svg, build_rectangle_svg, build_text_svg
|
||||
|
||||
|
||||
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_newline_normalization(self):
|
||||
svg, _, height, _ = build_text_svg("a\r\nb\rc", font_size=10)
|
||||
assert "\r" not in svg
|
||||
assert height == 3 * 19
|
||||
|
||||
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>")
|
||||
|
||||
@pytest.mark.parametrize("text", [None, "", "\n\n"])
|
||||
def test_empty_text_rejected(self, text):
|
||||
with pytest.raises(ValueError):
|
||||
build_text_svg(text)
|
||||
|
||||
def test_text_too_long(self):
|
||||
with pytest.raises(ValueError):
|
||||
build_text_svg("x" * 501)
|
||||
|
||||
def test_control_characters_rejected(self):
|
||||
with pytest.raises(ValueError):
|
||||
build_text_svg("a\x00b")
|
||||
|
||||
@pytest.mark.parametrize("font_size", [0, 201, "13", 13.5, True])
|
||||
def test_bad_font_size(self, font_size):
|
||||
with pytest.raises(ValueError):
|
||||
build_text_svg("R1", font_size=font_size)
|
||||
|
||||
@pytest.mark.parametrize("color", ["red", "123456", "#GGGGGG", "#12345", 'black" onload="x'])
|
||||
def test_bad_color(self, color):
|
||||
with pytest.raises(ValueError):
|
||||
build_text_svg("R1", color=color)
|
||||
|
||||
@pytest.mark.parametrize("font_family", ['mono" onload="x', "sans<serif>", "a&b", ""])
|
||||
def test_bad_font_family(self, font_family):
|
||||
with pytest.raises(ValueError):
|
||||
build_text_svg("R1", font_family=font_family)
|
||||
|
||||
def test_font_family_with_spaces_accepted(self):
|
||||
svg, _, _, _ = build_text_svg("R1", font_family="DejaVu Sans Mono")
|
||||
assert 'font-family="DejaVu Sans Mono"' in svg
|
||||
|
||||
|
||||
class TestBuildRectangleSvg:
|
||||
def test_plain_fill(self):
|
||||
svg, width, height = build_rectangle_svg(80, 50, fill="#4A90D9")
|
||||
assert svg == (
|
||||
'<svg height="50" width="80">'
|
||||
'<rect fill="#4A90D9" fill-opacity="1.0" height="50" width="80" rx="0" ry="0"/></svg>'
|
||||
)
|
||||
assert (width, height) == (80, 50)
|
||||
# no stroke requested -> no stroke attributes (Web UI serializer parity)
|
||||
assert "stroke" not in svg
|
||||
|
||||
def test_stroke_and_dashed(self):
|
||||
svg, _, _ = build_rectangle_svg(80, 50, fill="none", fill_opacity=0.5, stroke="#333333", stroke_width=2, dashed=True)
|
||||
assert 'fill="none"' in svg
|
||||
assert 'fill-opacity="0.5"' in svg
|
||||
assert 'stroke="#333333" stroke-width="2" stroke-dasharray="10,6"' in svg
|
||||
|
||||
def test_custom_dasharray(self):
|
||||
svg, _, _ = build_rectangle_svg(80, 50, stroke="#000000", dasharray="4,2")
|
||||
assert 'stroke-dasharray="4,2"' in svg
|
||||
|
||||
def test_corner_radius(self):
|
||||
svg, _, _ = build_rectangle_svg(100, 60, rx=8)
|
||||
assert 'rx="8" ry="8"' in svg
|
||||
|
||||
def test_fill_none_renders_unfilled(self):
|
||||
# the Web UI renders fill="none" as an outline-only box
|
||||
svg, _, _ = build_rectangle_svg(80, 50, fill="none", stroke="#333333")
|
||||
ET.fromstring(svg)
|
||||
|
||||
@pytest.mark.parametrize("width,height", [(0, 50), (80, 0), (-1, 50), (2001, 50), ("80", 50)])
|
||||
def test_bad_dimensions(self, width, height):
|
||||
with pytest.raises(ValueError):
|
||||
build_rectangle_svg(width, height)
|
||||
|
||||
def test_radius_too_large(self):
|
||||
with pytest.raises(ValueError):
|
||||
build_rectangle_svg(80, 50, rx=26)
|
||||
|
||||
@pytest.mark.parametrize("fill", ["red", 123, "transparent"])
|
||||
def test_bad_fill(self, fill):
|
||||
with pytest.raises(ValueError):
|
||||
build_rectangle_svg(80, 50, fill=fill)
|
||||
|
||||
@pytest.mark.parametrize("fill_opacity", [-0.1, 1.1, "0.5", True])
|
||||
def test_bad_fill_opacity(self, fill_opacity):
|
||||
with pytest.raises(ValueError):
|
||||
build_rectangle_svg(80, 50, fill_opacity=fill_opacity)
|
||||
|
||||
def test_bad_stroke(self):
|
||||
with pytest.raises(ValueError):
|
||||
build_rectangle_svg(80, 50, stroke="blue")
|
||||
|
||||
def test_bad_stroke_width(self):
|
||||
with pytest.raises(ValueError):
|
||||
build_rectangle_svg(80, 50, stroke="#333333", stroke_width=0)
|
||||
|
||||
@pytest.mark.parametrize("dasharray", ["25, 25", "5, 25", "abc", "5;5", '5,5" onload="x'])
|
||||
def test_bad_dasharray(self, dasharray):
|
||||
with pytest.raises(ValueError):
|
||||
build_rectangle_svg(80, 50, stroke="#333333", dasharray=dasharray)
|
||||
|
||||
def test_dasharray_without_stroke(self):
|
||||
with pytest.raises(ValueError):
|
||||
build_rectangle_svg(80, 50, dasharray="5,5")
|
||||
|
||||
|
||||
class TestBuildEllipseSvg:
|
||||
def test_bounding_box_derivation(self):
|
||||
svg, width, height = build_ellipse_svg(100, 60, fill="#5AA9DD", fill_opacity=0.8)
|
||||
assert svg == (
|
||||
'<svg height="60" width="100">'
|
||||
'<ellipse fill="#5AA9DD" fill-opacity="0.8" cx="50" cy="30" rx="50" ry="30"/></svg>'
|
||||
)
|
||||
assert (width, height) == (100, 60)
|
||||
|
||||
def test_odd_dimensions_round_down(self):
|
||||
svg, width, height = build_ellipse_svg(101, 61)
|
||||
assert (width, height) == (100, 60)
|
||||
assert 'cx="50" cy="30" rx="50" ry="30"' in svg
|
||||
|
||||
def test_circle(self):
|
||||
svg, width, height = build_ellipse_svg(80, 80, stroke="#333333")
|
||||
assert width == height == 80
|
||||
assert 'rx="40" ry="40"' in svg
|
||||
|
||||
def test_too_small(self):
|
||||
with pytest.raises(ValueError):
|
||||
build_ellipse_svg(1, 60)
|
||||
|
||||
def test_stroke_and_dashed(self):
|
||||
svg, _, _ = build_ellipse_svg(100, 60, fill="none", stroke="#E74C3C", stroke_width=2, dashed=True)
|
||||
assert 'stroke-dasharray="10,6"' in svg
|
||||
ET.fromstring(svg)
|
||||
Reference in new issue
Block a user