mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-12 05:25:31 +03: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:
parent
73cbb0387c
commit
5e8272fdd8
@ -77,7 +77,10 @@ from gns3server.agent.gns3_copilot.skills.registry import get_prompt
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import (
|
||||
ExecuteMultipleDeviceCommands,
|
||||
ExecuteMultipleDeviceConfigCommands,
|
||||
GNS3CreateEllipseDrawingTool,
|
||||
GNS3CreateNodeTool,
|
||||
GNS3CreateRectangleDrawingTool,
|
||||
GNS3CreateTextDrawingTool,
|
||||
GNS3LinkTool,
|
||||
GNS3PacketFilterTool,
|
||||
GNS3StartNodeTool,
|
||||
@ -103,6 +106,9 @@ TEACHING_ASSISTANT_MODE_TOOLS = [
|
||||
GNS3TemplateTool(), # Get GNS3 node templates
|
||||
GNS3CreateNodeTool(), # Create new nodes in GNS3
|
||||
GNS3LinkTool(), # Create links between nodes
|
||||
GNS3CreateTextDrawingTool(), # Add text labels to the canvas
|
||||
GNS3CreateRectangleDrawingTool(), # Add rectangle annotations to the canvas
|
||||
GNS3CreateEllipseDrawingTool(), # Add ellipse annotations to the canvas
|
||||
GNS3StartNodeTool(), # Start GNS3 nodes (returns immediately)
|
||||
GNS3WaitTool(), # Wait for nodes to boot (pair with start_gns3_node)
|
||||
GNS3UpdateNodeNameTool(), # Update node name
|
||||
@ -118,6 +124,9 @@ LAB_AUTOMATION_ASSISTANT_MODE_TOOLS = [
|
||||
GNS3TemplateTool(), # Get GNS3 node templates
|
||||
GNS3CreateNodeTool(), # Create new nodes in GNS3
|
||||
GNS3LinkTool(), # Create links between nodes
|
||||
GNS3CreateTextDrawingTool(), # Add text labels to the canvas
|
||||
GNS3CreateRectangleDrawingTool(), # Add rectangle annotations to the canvas
|
||||
GNS3CreateEllipseDrawingTool(), # Add ellipse annotations to the canvas
|
||||
GNS3StartNodeTool(), # Start GNS3 nodes (returns immediately)
|
||||
GNS3WaitTool(), # Wait for nodes to boot (pair with start_gns3_node)
|
||||
GNS3StopNodeTool(), # Stop GNS3 nodes
|
||||
|
||||
@ -55,6 +55,7 @@ from typing import Any
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
from gns3server.services import access_ticket_service
|
||||
from gns3server.services.access_tickets import DEFAULT_TICKET_TTL
|
||||
from gns3server.utils.drawings import build_ellipse_svg, build_rectangle_svg, build_text_svg
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@ -1076,3 +1077,95 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
|
||||
# action == "delete"
|
||||
conn.http_call("delete", url)
|
||||
return {"message": f"Marker definition '{def_name}' deleted", "project_id": project_id, "def_name": def_name}
|
||||
|
||||
|
||||
# ── Drawing handlers ──────────────────────────────────────────────────────
|
||||
# SVG assembly and validation live in gns3server.utils.drawings (Web UI
|
||||
# compatible format); these handlers only carry params and do the REST call.
|
||||
|
||||
|
||||
def create_text_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a canvas text label; the SVG is assembled from validated params."""
|
||||
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
try:
|
||||
svg, width, height, line_height = build_text_svg(
|
||||
params.get("text"),
|
||||
font_size=params.get("font_size", 13),
|
||||
color=params.get("color", "#000000"),
|
||||
bold=params.get("bold", False),
|
||||
font_family=params.get("font_family", "monospace"),
|
||||
)
|
||||
except ValueError as e:
|
||||
return {"error": str(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 create_rectangle_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a canvas rectangle; the SVG is assembled from validated params."""
|
||||
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
width = params.get("width")
|
||||
height = params.get("height")
|
||||
if width is None or height is None:
|
||||
return {"error": "width and height are required"}
|
||||
try:
|
||||
svg, width, height = build_rectangle_svg(
|
||||
width,
|
||||
height,
|
||||
fill=params.get("fill", "#FFFFFF"),
|
||||
fill_opacity=params.get("fill_opacity", 1.0),
|
||||
stroke=params.get("stroke"),
|
||||
stroke_width=params.get("stroke_width", 1),
|
||||
dashed=params.get("dashed", False),
|
||||
dasharray=params.get("dasharray"),
|
||||
rx=params.get("rx", 0),
|
||||
)
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {"svg": svg, "x": params.get("x", 0), "y": params.get("y", 0), "z": params.get("z", 0)}
|
||||
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json()
|
||||
return {"message": "Rectangle drawing created", "drawing": result, "width": width, "height": height}
|
||||
|
||||
|
||||
def create_ellipse_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a canvas ellipse from a bounding box; the SVG is assembled from validated params."""
|
||||
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
width = params.get("width")
|
||||
height = params.get("height")
|
||||
if width is None or height is None:
|
||||
return {"error": "width and height are required"}
|
||||
try:
|
||||
svg, width, height = build_ellipse_svg(
|
||||
width,
|
||||
height,
|
||||
fill=params.get("fill", "#FFFFFF"),
|
||||
fill_opacity=params.get("fill_opacity", 1.0),
|
||||
stroke=params.get("stroke"),
|
||||
stroke_width=params.get("stroke_width", 1),
|
||||
dashed=params.get("dashed", False),
|
||||
dasharray=params.get("dasharray"),
|
||||
)
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {"svg": svg, "x": params.get("x", 0), "y": params.get("y", 0), "z": params.get("z", 0)}
|
||||
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json()
|
||||
return {"message": "Ellipse drawing created", "drawing": result, "width": width, "height": height}
|
||||
|
||||
@ -40,6 +40,7 @@ Main modules:
|
||||
- vpcs_tools_netmiko: VPCS device configuration tool using Netmiko
|
||||
- gns3_create_node: GNS3 node creation tool
|
||||
- gns3_create_link: GNS3 link creation tool
|
||||
- gns3_create_drawing: GNS3 canvas drawing tools (text, rectangle, ellipse)
|
||||
- gns3_start_node: GNS3 node startup tool (immediate return, no boot wait)
|
||||
- gns3_wait: Wait timer tool (pair with node start to let devices boot)
|
||||
- gns3_get_node_temp: GNS3 template retrieval tool
|
||||
@ -54,6 +55,11 @@ Author: Yue Guobin (岳国宾)
|
||||
# Import main tool classes
|
||||
from .config_tools_nornir import ExecuteMultipleDeviceConfigCommands
|
||||
from .display_tools_nornir import ExecuteMultipleDeviceCommands
|
||||
from .gns3_create_drawing import (
|
||||
GNS3CreateEllipseDrawingTool,
|
||||
GNS3CreateRectangleDrawingTool,
|
||||
GNS3CreateTextDrawingTool,
|
||||
)
|
||||
from .gns3_create_link import GNS3LinkTool
|
||||
from .gns3_create_node import GNS3CreateNodeTool
|
||||
from .gns3_get_node_temp import GNS3TemplateTool
|
||||
@ -81,7 +87,10 @@ __url__ = "https://github.com/yueguobin/gns3-copilot"
|
||||
__all__ = [
|
||||
"ExecuteMultipleDeviceCommands",
|
||||
"ExecuteMultipleDeviceConfigCommands",
|
||||
"GNS3CreateEllipseDrawingTool",
|
||||
"GNS3CreateNodeTool",
|
||||
"GNS3CreateRectangleDrawingTool",
|
||||
"GNS3CreateTextDrawingTool",
|
||||
"GNS3LinkTool",
|
||||
"GNS3PacketFilterTool",
|
||||
"GNS3StartNodeQuickTool",
|
||||
|
||||
162
gns3server/agent/gns3_copilot/tools_v2/gns3_create_drawing.py
Normal file
162
gns3server/agent/gns3_copilot/tools_v2/gns3_create_drawing.py
Normal file
@ -0,0 +1,162 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
|
||||
#
|
||||
# This file is part of GNS3-Copilot project.
|
||||
#
|
||||
# GNS3-Copilot is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU General Public License as published by the
|
||||
# Free Software Foundation, either version 3 of the License, or (at your
|
||||
# option) any later version.
|
||||
#
|
||||
# GNS3-Copilot is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
# for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with GNS3-Copilot. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# Copyright (C) 2025 Yue Guobin (岳国宾)
|
||||
# Author: Yue Guobin (岳国宾)
|
||||
#
|
||||
# Project Home: https://github.com/yueguobin/gns3-copilot
|
||||
#
|
||||
|
||||
"""
|
||||
GNS3 canvas drawing tools for text, rectangle and ellipse annotations.
|
||||
|
||||
Thin LangChain wrappers over the shared REST handlers in
|
||||
``gns3_client.api_handlers``; the Web UI compatible SVG is assembled and
|
||||
validated by ``gns3server.utils.drawings`` (single implementation shared
|
||||
with the MCP drawing tools).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx,
|
||||
create_ellipse_drawing_handler,
|
||||
create_rectangle_drawing_handler,
|
||||
create_text_drawing_handler,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _GNS3CreateDrawingToolBase(BaseTool):
|
||||
"""Shared plumbing: parse JSON input, build ctx, dispatch to a handler."""
|
||||
|
||||
def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str,
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
input_data = json.loads(tool_input)
|
||||
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
|
||||
|
||||
return self._dispatch(input_data, gns3_ctx)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Invalid JSON input: %s", e)
|
||||
return {"error": f"Invalid JSON input: {e}"}
|
||||
except Exception as e:
|
||||
logger.exception("Drawing creation failed")
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
class GNS3CreateTextDrawingTool(_GNS3CreateDrawingToolBase):
|
||||
"""
|
||||
A LangChain tool to add a text label to the GNS3 project canvas.
|
||||
|
||||
**Input**:
|
||||
A JSON object with project_id, x, y (top-left corner on the canvas)
|
||||
and text; optional font_size (1-200, default 13), color (#RRGGBB,
|
||||
default #000000), bold (default false), font_family (default
|
||||
monospace) and z (default 1, keeps text above links).
|
||||
Newlines in text start additional lines; XML characters are escaped
|
||||
automatically; the computed width/height are returned.
|
||||
|
||||
**Output**:
|
||||
A dictionary with the created drawing, its drawing_id and the
|
||||
computed width/height/line_height, or an "error" key on failure.
|
||||
"""
|
||||
|
||||
name: str = "create_gns3_text_drawing"
|
||||
description: str = """
|
||||
Adds a text label to the GNS3 project canvas.
|
||||
Input: JSON with project_id, x, y (top-left corner), text and optional
|
||||
font_size, color, bold, font_family, z.
|
||||
Returns: the created drawing with its computed width and height.
|
||||
"""
|
||||
|
||||
def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
return create_text_drawing_handler(input_data, gns3_ctx)
|
||||
|
||||
|
||||
class GNS3CreateRectangleDrawingTool(_GNS3CreateDrawingToolBase):
|
||||
"""
|
||||
A LangChain tool to add a rectangle to the GNS3 project canvas.
|
||||
|
||||
**Input**:
|
||||
A JSON object with project_id, x, y (top-left corner), width and
|
||||
height (1-2000); optional fill (#RRGGBB or "none", default #FFFFFF),
|
||||
fill_opacity (0-1, default 1.0), stroke (#RRGGBB, no border by
|
||||
default), stroke_width (1-20), dashed (bool), dasharray (like "4,2",
|
||||
requires stroke) and rx (corner radius, default 0). z defaults to 0
|
||||
so shapes stay behind nodes and text.
|
||||
|
||||
**Output**:
|
||||
A dictionary with the created drawing, or an "error" key on failure.
|
||||
"""
|
||||
|
||||
name: str = "create_gns3_rectangle_drawing"
|
||||
description: str = """
|
||||
Adds a rectangle to the GNS3 project canvas.
|
||||
Input: JSON with project_id, x, y, width, height and optional fill,
|
||||
fill_opacity, stroke, stroke_width, dashed, dasharray, rx, z.
|
||||
Returns: the created drawing with its width and height.
|
||||
"""
|
||||
|
||||
def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
return create_rectangle_drawing_handler(input_data, gns3_ctx)
|
||||
|
||||
|
||||
class GNS3CreateEllipseDrawingTool(_GNS3CreateDrawingToolBase):
|
||||
"""
|
||||
A LangChain tool to add an ellipse (or circle) to the GNS3 canvas.
|
||||
|
||||
**Input**:
|
||||
A JSON object with project_id, x, y (bounding box top-left corner),
|
||||
width and height (2-2000, equal values give a circle); the same
|
||||
optional style fields as the rectangle tool. cx/cy are derived from
|
||||
the bounding box; for odd width/height the drawing is 1 px smaller
|
||||
than requested and the actual size is returned.
|
||||
|
||||
**Output**:
|
||||
A dictionary with the created drawing, or an "error" key on failure.
|
||||
"""
|
||||
|
||||
name: str = "create_gns3_ellipse_drawing"
|
||||
description: str = """
|
||||
Adds an ellipse or circle to the GNS3 project canvas.
|
||||
Input: JSON with project_id, x, y, width, height (bounding box) and
|
||||
optional fill, fill_opacity, stroke, stroke_width, dashed, dasharray, z.
|
||||
Returns: the created drawing with its actual width and height.
|
||||
"""
|
||||
|
||||
def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
return create_ellipse_drawing_handler(input_data, gns3_ctx)
|
||||
@ -33,6 +33,8 @@ Supports ellipse and rectangle shapes for two-node annotations.
|
||||
import math
|
||||
from typing import Any, Literal
|
||||
|
||||
from gns3server.utils.drawings import build_ellipse_svg, build_rectangle_svg, build_text_svg
|
||||
|
||||
# Default parameters
|
||||
DEFAULT_DEVICE_WIDTH = 50
|
||||
DEFAULT_DEVICE_HEIGHT = 50
|
||||
@ -144,10 +146,16 @@ def calculate_two_node_shape(
|
||||
center_y = (node1_center_y + node2_center_y) / 2
|
||||
|
||||
color_scheme = _get_color_scheme(area_name)
|
||||
text_svg = generate_text_svg(area_name, color_scheme)
|
||||
|
||||
text_svg_width = len(area_name) * 8 + 20
|
||||
text_svg_height = DEFAULT_FONT_SIZE + 16
|
||||
# Shared builder: Web UI compatible SVG plus the computed label size
|
||||
# (the old len * 8 + 20 estimate ignored the Web UI's pt-based rendering).
|
||||
text_svg, text_svg_width, text_svg_height, _ = build_text_svg(
|
||||
area_name,
|
||||
font_size=DEFAULT_FONT_SIZE,
|
||||
color=color_scheme["stroke"],
|
||||
bold=True,
|
||||
font_family="TypeWriter",
|
||||
)
|
||||
|
||||
if shape_type == "ellipse":
|
||||
rx = distance / 2
|
||||
@ -236,8 +244,11 @@ def generate_ellipse_svg(
|
||||
svg_width: int,
|
||||
svg_height: int,
|
||||
) -> str:
|
||||
"""Generate SVG for ellipse."""
|
||||
return f"""<svg width="{svg_width}" height="{svg_height}"><ellipse cx="{rx}" cy="{ry}" rx="{rx}" ry="{ry}" fill="{color_scheme["fill"]}" fill-opacity="{color_scheme["fill_opacity"]}"/></svg>"""
|
||||
"""Generate SVG for ellipse (delegates to the shared Web UI compatible builder)."""
|
||||
svg, _, _ = build_ellipse_svg(
|
||||
rx * 2, ry * 2, fill=color_scheme["fill"], fill_opacity=color_scheme["fill_opacity"]
|
||||
)
|
||||
return svg
|
||||
|
||||
|
||||
def generate_rectangle_svg(
|
||||
@ -245,16 +256,23 @@ def generate_rectangle_svg(
|
||||
height: int,
|
||||
color_scheme: dict[str, Any],
|
||||
) -> str:
|
||||
"""Generate SVG for rectangle."""
|
||||
return f"""<svg width="{width}" height="{height}"><rect x="0" y="0" width="{width}" height="{height}" fill="{color_scheme["fill"]}" fill-opacity="{color_scheme["fill_opacity"]}"/></svg>"""
|
||||
"""Generate SVG for rectangle (delegates to the shared Web UI compatible builder)."""
|
||||
svg, _, _ = build_rectangle_svg(
|
||||
width, height, fill=color_scheme["fill"], fill_opacity=color_scheme["fill_opacity"]
|
||||
)
|
||||
return svg
|
||||
|
||||
|
||||
def generate_text_svg(text: str, color_scheme: dict[str, Any]) -> str:
|
||||
"""Generate SVG for text label."""
|
||||
text_width = len(text) * 8 + 20
|
||||
text_height = DEFAULT_FONT_SIZE + 16
|
||||
|
||||
return f"""<svg width="{text_width}" height="{text_height}"><text font-family="TypeWriter" font-size="{DEFAULT_FONT_SIZE}.0" font-weight="bold" fill="{color_scheme["stroke"]}" text-anchor="middle" x="{text_width / 2}" y="{text_height / 2 + 4}">{text}</text></svg>"""
|
||||
"""Generate SVG for text label (delegates to the shared Web UI compatible builder)."""
|
||||
svg, _, _, _ = build_text_svg(
|
||||
text,
|
||||
font_size=DEFAULT_FONT_SIZE,
|
||||
color=color_scheme["stroke"],
|
||||
bold=True,
|
||||
font_family="TypeWriter",
|
||||
)
|
||||
return svg
|
||||
|
||||
|
||||
def _hsv_to_hex(h: int, s: int, v: int) -> str:
|
||||
|
||||
@ -45,8 +45,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
available_filters_handler,
|
||||
create_ellipse_drawing_handler,
|
||||
create_link_handler,
|
||||
create_node_handler,
|
||||
create_rectangle_drawing_handler,
|
||||
create_text_drawing_handler,
|
||||
delete_link_handler,
|
||||
delete_node_file_handler,
|
||||
delete_node_handler,
|
||||
@ -110,7 +113,6 @@ from .device_config import (
|
||||
)
|
||||
from .drawings import (
|
||||
create_drawing_handler,
|
||||
create_text_drawing_handler,
|
||||
delete_drawing_handler,
|
||||
get_drawing_handler,
|
||||
get_drawings_handler,
|
||||
@ -1627,6 +1629,103 @@ async def drawing_create_text(
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def drawing_create_rectangle(
|
||||
project_id: Annotated[str, Field(description="Project name or UUID")],
|
||||
x: Annotated[int, Field(description="X coordinate of the rectangle top-left corner on the canvas")],
|
||||
y: Annotated[int, Field(description="Y coordinate of the rectangle top-left corner on the canvas")],
|
||||
width: Annotated[int, Field(description="Rectangle width in px, 1-2000")],
|
||||
height: Annotated[int, Field(description="Rectangle height in px, 1-2000")],
|
||||
fill: Annotated[str, Field(description='Fill color, hex like #RRGGBB or "none" (default: #FFFFFF)')] = "#FFFFFF",
|
||||
fill_opacity: Annotated[float, Field(description="Fill opacity, 0-1 (default: 1.0)")] = 1.0,
|
||||
stroke: Annotated[str | None, Field(description="Border color, hex like #RRGGBB; omit for no border")] = None,
|
||||
stroke_width: Annotated[int, Field(description="Border width, 1-20 (default: 1)")] = 1,
|
||||
dashed: Annotated[bool, Field(description="Dashed border, uses the default 10,6 pattern (default: false)")] = False,
|
||||
dasharray: Annotated[str | None, Field(description='Custom dash pattern like "4,2"; requires stroke')] = None,
|
||||
rx: Annotated[int, Field(description="Corner radius, 0 to min(width, height)/2 (default: 0)")] = 0,
|
||||
z: Annotated[int, Field(description="Z layer; 0 keeps shapes behind nodes and text (default: 0)")] = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Add a rectangle 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.
|
||||
|
||||
- fill "none" renders an outline-only box (stroke recommended then).
|
||||
- Border attributes are only emitted when stroke is given.
|
||||
- dasharray must be plain "on,off" numbers; the Qt-remapped patterns
|
||||
("25, 25", "5, 25", ...) are rejected because the Web UI rewrites them.
|
||||
|
||||
Example: semi-transparent outline box at (200, 150):
|
||||
drawing_create_rectangle(project_id="demo", x=200, y=150, width=400, height=200,
|
||||
fill="none", stroke="#333333", dashed=True)
|
||||
"""
|
||||
return await asyncio.to_thread(
|
||||
_run_handler_sync,
|
||||
create_rectangle_drawing_handler,
|
||||
{
|
||||
"project_id": project_id,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"z": z,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"fill": fill,
|
||||
"fill_opacity": fill_opacity,
|
||||
"stroke": stroke,
|
||||
"stroke_width": stroke_width,
|
||||
"dashed": dashed,
|
||||
"dasharray": dasharray,
|
||||
"rx": rx,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def drawing_create_ellipse(
|
||||
project_id: Annotated[str, Field(description="Project name or UUID")],
|
||||
x: Annotated[int, Field(description="X coordinate of the bounding box top-left corner on the canvas")],
|
||||
y: Annotated[int, Field(description="Y coordinate of the bounding box top-left corner on the canvas")],
|
||||
width: Annotated[int, Field(description="Bounding box width in px, 2-2000; equal to height gives a circle")],
|
||||
height: Annotated[int, Field(description="Bounding box height in px, 2-2000")],
|
||||
fill: Annotated[str, Field(description='Fill color, hex like #RRGGBB or "none" (default: #FFFFFF)')] = "#FFFFFF",
|
||||
fill_opacity: Annotated[float, Field(description="Fill opacity, 0-1 (default: 1.0)")] = 1.0,
|
||||
stroke: Annotated[str | None, Field(description="Border color, hex like #RRGGBB; omit for no border")] = None,
|
||||
stroke_width: Annotated[int, Field(description="Border width, 1-20 (default: 1)")] = 1,
|
||||
dashed: Annotated[bool, Field(description="Dashed border, uses the default 10,6 pattern (default: false)")] = False,
|
||||
dasharray: Annotated[str | None, Field(description='Custom dash pattern like "4,2"; requires stroke')] = None,
|
||||
z: Annotated[int, Field(description="Z layer; 0 keeps shapes behind nodes and text (default: 0)")] = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Add an ellipse (or circle) to a project canvas.
|
||||
|
||||
The SVG is assembled server-side in the Web UI compatible format. cx/cy are
|
||||
derived from the bounding box (the Web UI treats them as offsets inside the
|
||||
drawing, not canvas coordinates); for odd width/height the drawing is 1 px
|
||||
smaller than requested and the actual size is returned.
|
||||
|
||||
Example: circle of diameter 160 at (300, 300):
|
||||
drawing_create_ellipse(project_id="demo", x=300, y=300, width=160, height=160,
|
||||
fill="#5AA9DD", fill_opacity=0.8)
|
||||
"""
|
||||
return await asyncio.to_thread(
|
||||
_run_handler_sync,
|
||||
create_ellipse_drawing_handler,
|
||||
{
|
||||
"project_id": project_id,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"z": z,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"fill": fill,
|
||||
"fill_opacity": fill_opacity,
|
||||
"stroke": stroke,
|
||||
"stroke_width": stroke_width,
|
||||
"dashed": dashed,
|
||||
"dasharray": dasharray,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def drawing_get(
|
||||
project_id: Annotated[str, Field(description="Project name or UUID")],
|
||||
|
||||
@ -20,11 +20,7 @@ MCP tool handlers for GNS3 drawing management.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@ -73,101 +69,6 @@ def create_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
|
||||
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]:
|
||||
project_id = params.get("project_id")
|
||||
drawing_id = params.get("drawing_id")
|
||||
|
||||
248
gns3server/utils/drawings.py
Normal file
248
gns3server/utils/drawings.py
Normal file
@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Web UI compatible drawing SVG assembly.
|
||||
|
||||
The GNS3 Web UI does not render drawing SVG as-is: it re-parses it into its own
|
||||
element model with ONE recognized child element per drawing (text, image, rect,
|
||||
line, ellipse or path) and reads the root width/height for the selection box.
|
||||
These helpers emit exactly that format, mirroring the attribute sets the Web UI
|
||||
itself serializes, and validate inputs so callers fail loudly instead of hitting
|
||||
the controller's silent drop of unparseable SVG strings of 500+ characters.
|
||||
|
||||
Shared by the MCP tools (agent/mcp) and the GNS3 copilot tools (tools_v2).
|
||||
"""
|
||||
|
||||
import re
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
# 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 ,.'-]+$")
|
||||
_DASHARRAY_RE = re.compile(r"^[0-9][0-9, ]*$")
|
||||
# The Web UI remaps these Qt-style dash patterns to different values
|
||||
# (QtDasharrayFixer); reject them so what you send is what renders.
|
||||
_QT_REMAPPED_DASHARRAYS = {"25, 25", "5, 25", "5, 25, 25", "25, 25, 5, 25, 5"}
|
||||
_DEFAULT_DASHARRAY = "10,6"
|
||||
|
||||
|
||||
def _checked_text(text: Any) -> str:
|
||||
if text is None:
|
||||
raise ValueError("text is required")
|
||||
if not isinstance(text, str):
|
||||
raise ValueError("text must be a string")
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n").strip("\n")
|
||||
if not text:
|
||||
raise ValueError("text is required")
|
||||
if len(text) > _MAX_TEXT_LENGTH:
|
||||
raise ValueError(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:
|
||||
raise ValueError("text contains control characters (only newlines are allowed)")
|
||||
return text
|
||||
|
||||
|
||||
def _checked_font_size(font_size: Any) -> int:
|
||||
if isinstance(font_size, bool) or not isinstance(font_size, int) or not 1 <= font_size <= 200:
|
||||
raise ValueError("font_size must be an integer between 1 and 200")
|
||||
return font_size
|
||||
|
||||
|
||||
def _checked_font_family(font_family: Any) -> str:
|
||||
if not isinstance(font_family, str) or not _FONT_FAMILY_RE.match(font_family):
|
||||
raise ValueError("font_family contains invalid characters (allowed: letters, digits, spaces, . ' -)")
|
||||
return font_family
|
||||
|
||||
|
||||
def _checked_color(value: Any, name: str) -> str:
|
||||
if not isinstance(value, str) or not _COLOR_RE.match(value):
|
||||
raise ValueError(f"{name} must be a hex value like #RRGGBB")
|
||||
return value
|
||||
|
||||
|
||||
def _checked_dimension(value: Any, name: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 2000:
|
||||
raise ValueError(f"{name} must be an integer between 1 and 2000")
|
||||
return value
|
||||
|
||||
|
||||
def _checked_radius(value: Any, max_radius: int) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= max_radius:
|
||||
raise ValueError(f"rx must be an integer between 0 and {max_radius}")
|
||||
return value
|
||||
|
||||
|
||||
def _checked_style(
|
||||
fill: Any, fill_opacity: Any, stroke: Any, stroke_width: Any, dashed: Any, dasharray: Any
|
||||
) -> tuple[str, Any, str | None, int, str | None]:
|
||||
"""
|
||||
Validate the style block shared by rectangle and ellipse drawings.
|
||||
|
||||
:returns: (fill, fill_opacity, stroke, stroke_width, dasharray-or-None)
|
||||
"""
|
||||
|
||||
if fill != "none" and (not isinstance(fill, str) or not _COLOR_RE.match(fill)):
|
||||
raise ValueError('fill must be a hex value like #RRGGBB or "none"')
|
||||
if isinstance(fill_opacity, bool) or not isinstance(fill_opacity, (int, float)) or not 0 <= fill_opacity <= 1:
|
||||
raise ValueError("fill_opacity must be a number between 0 and 1")
|
||||
|
||||
dash = dasharray if dasharray is not None else (_DEFAULT_DASHARRAY if dashed else None)
|
||||
if dash is not None:
|
||||
if not isinstance(dash, str) or not _DASHARRAY_RE.match(dash) or dash in _QT_REMAPPED_DASHARRAYS:
|
||||
raise ValueError(
|
||||
'dasharray must be plain "on,off" number pairs and must not be one of '
|
||||
f"the Qt-remapped patterns {sorted(_QT_REMAPPED_DASHARRAYS)}"
|
||||
)
|
||||
if stroke is None:
|
||||
raise ValueError("stroke is required when dasharray is set")
|
||||
|
||||
if stroke is not None:
|
||||
stroke = _checked_color(stroke, "stroke")
|
||||
if isinstance(stroke_width, bool) or not isinstance(stroke_width, int) or not 1 <= stroke_width <= 20:
|
||||
raise ValueError("stroke_width must be an integer between 1 and 20")
|
||||
|
||||
return fill, fill_opacity, stroke, stroke_width, dash
|
||||
|
||||
|
||||
def _style_attrs(fill: str, fill_opacity: Any, stroke: str | None, stroke_width: int, dash: str | None) -> str:
|
||||
"""Render the validated style block as SVG attributes (Web UI attribute order)."""
|
||||
|
||||
attrs = f'fill="{fill}" fill-opacity="{fill_opacity}"'
|
||||
if stroke is not None:
|
||||
attrs += f' stroke="{stroke}" stroke-width="{stroke_width}"'
|
||||
if dash is not None:
|
||||
attrs += f' stroke-dasharray="{dash}"'
|
||||
return attrs
|
||||
|
||||
|
||||
def build_text_svg(
|
||||
text: Any, 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
|
||||
"""
|
||||
|
||||
text = _checked_text(text)
|
||||
font_size = _checked_font_size(font_size)
|
||||
color = _checked_color(color, "color")
|
||||
font_family = _checked_font_family(font_family)
|
||||
|
||||
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>'
|
||||
)
|
||||
ET.fromstring(svg)
|
||||
return svg, width, height, line_height
|
||||
|
||||
|
||||
def build_rectangle_svg(
|
||||
width: int,
|
||||
height: int,
|
||||
fill: str = "#FFFFFF",
|
||||
fill_opacity: float = 1.0,
|
||||
stroke: str | None = None,
|
||||
stroke_width: int = 1,
|
||||
dashed: bool = False,
|
||||
dasharray: str | None = None,
|
||||
rx: int = 0,
|
||||
) -> tuple[str, int, int]:
|
||||
"""
|
||||
Build a Web UI compatible single <rect> SVG.
|
||||
|
||||
The rect sits at the drawing origin (the Web UI ignores rect x/y), so the
|
||||
caller positions the drawing itself and the root size equals the rect size.
|
||||
|
||||
:returns: (svg, width, height)
|
||||
"""
|
||||
|
||||
width = _checked_dimension(width, "width")
|
||||
height = _checked_dimension(height, "height")
|
||||
rx = _checked_radius(rx, min(width, height) // 2)
|
||||
fill, fill_opacity, stroke, stroke_width, dash = _checked_style(
|
||||
fill, fill_opacity, stroke, stroke_width, dashed, dasharray
|
||||
)
|
||||
|
||||
svg = (
|
||||
f'<svg height="{height}" width="{width}">'
|
||||
f'<rect {_style_attrs(fill, fill_opacity, stroke, stroke_width, dash)} '
|
||||
f'height="{height}" width="{width}" rx="{rx}" ry="{rx}"/></svg>'
|
||||
)
|
||||
ET.fromstring(svg)
|
||||
return svg, width, height
|
||||
|
||||
|
||||
def build_ellipse_svg(
|
||||
width: int,
|
||||
height: int,
|
||||
fill: str = "#FFFFFF",
|
||||
fill_opacity: float = 1.0,
|
||||
stroke: str | None = None,
|
||||
stroke_width: int = 1,
|
||||
dashed: bool = False,
|
||||
dasharray: str | None = None,
|
||||
) -> tuple[str, int, int]:
|
||||
"""
|
||||
Build a Web UI compatible single <ellipse> SVG from a bounding box.
|
||||
|
||||
The radii and center are derived (rx = width // 2, cx = rx) because the Web UI
|
||||
treats cx/cy as offsets inside the drawing box, not canvas coordinates. For
|
||||
odd width/height the drawing is 1 px smaller than requested.
|
||||
|
||||
:returns: (svg, width, height) with the actual (possibly rounded-down) size
|
||||
"""
|
||||
|
||||
width = _checked_dimension(width, "width")
|
||||
height = _checked_dimension(height, "height")
|
||||
rx, ry = width // 2, height // 2
|
||||
if rx < 1 or ry < 1:
|
||||
raise ValueError("width and height must be at least 2")
|
||||
fill, fill_opacity, stroke, stroke_width, dash = _checked_style(
|
||||
fill, fill_opacity, stroke, stroke_width, dashed, dasharray
|
||||
)
|
||||
|
||||
svg = (
|
||||
f'<svg height="{2 * ry}" width="{2 * rx}">'
|
||||
f'<ellipse {_style_attrs(fill, fill_opacity, stroke, stroke_width, dash)} '
|
||||
f'cx="{rx}" cy="{ry}" rx="{rx}" ry="{ry}"/></svg>'
|
||||
)
|
||||
ET.fromstring(svg)
|
||||
return svg, 2 * rx, 2 * ry
|
||||
@ -165,8 +165,6 @@ extend-immutable-calls = [
|
||||
"gns3server/agent/gns3_copilot/utils/parse_tool_content.py" = ["C901", "DTZ003"]
|
||||
"gns3server/agent/mcp/__init__.py" = ["RUF003"]
|
||||
"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/setup_wireshark_image.py" = ["PLW1510", "S603", "S607"]
|
||||
"gns3server/agent/web_wireshark/stats.py" = ["ASYNC221", "PLW1510", "S603", "S607"]
|
||||
@ -213,6 +211,8 @@ extend-immutable-calls = [
|
||||
"gns3server/db/tasks.py" = ["RUF006", "RUF019"]
|
||||
"gns3server/db_migrations/versions/ec4b7b198555_convert_pickletype_fields_to_json.py" = ["S301", "S608"]
|
||||
"gns3server/logger.py" = ["PLW0127"]
|
||||
# Parses self-generated SVG (validated inputs) like controller/drawing.py does.
|
||||
"gns3server/utils/drawings.py" = ["S314"]
|
||||
"gns3server/schemas/controller/settings.py" = ["S105"]
|
||||
"gns3server/schemas/controller/templates/docker_templates.py" = ["W292"]
|
||||
"gns3server/schemas/controller/tokens.py" = ["S105"]
|
||||
@ -236,8 +236,9 @@ extend-immutable-calls = [
|
||||
"scripts/extract_mermaid.py" = ["C901", "PLW1510", "S603", "S607"]
|
||||
"scripts/tag.py" = ["S603", "S607"]
|
||||
"scripts/welcome.py" = ["C901", "E722", "PLW1510", "S108", "S112", "S603", "S605", "S606", "S607"]
|
||||
"tests/agent/mcp/test_drawings.py" = ["S314"]
|
||||
"tests/agent/mcp/test_drawing_handlers.py" = ["S314"]
|
||||
"tests/agent/mcp/test_handlers.py" = ["B905"]
|
||||
"tests/utils/test_drawings.py" = ["S314"]
|
||||
"tests/agent/mcp/test_tool_params.py" = ["C901"]
|
||||
"tests/api/test_openapi_spec.py" = ["S603"]
|
||||
"tests/api/routes/controller/test_images.py" = ["S324", "W292"]
|
||||
|
||||
153
tests/agent/mcp/test_drawing_handlers.py
Normal file
153
tests/agent/mcp/test_drawing_handlers.py
Normal file
@ -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",
|
||||
|
||||
192
tests/utils/test_drawings.py
Normal file
192
tests/utils/test_drawings.py
Normal file
@ -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)
|
||||
Loading…
x
Reference in New Issue
Block a user