Skip to content

Commit 79df12e

Browse files
committed
Refactor unit tests to conform to existing test patterns
1 parent a19405a commit 79df12e

4 files changed

Lines changed: 277 additions & 255 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""The built-in ``get_dash_component`` tool.
2+
3+
Lets LLMs inspect a component's current properties and their relationships
4+
to callbacks (which tools write/read each prop).
5+
"""
6+
7+
from dash import Dash, Input, Output, dcc, html
8+
9+
from tests.unit.mcp.conftest import _call_tool, _make_app, _tools_list
10+
11+
12+
def test_mcpg001_present_in_tools_list():
13+
app = _make_app()
14+
tool_names = [t.name for t in _tools_list(app)]
15+
assert "get_dash_component" in tool_names
16+
17+
18+
def test_mcpg002_returns_structured_output_with_prop():
19+
app = Dash(__name__)
20+
app.layout = html.Div(
21+
[
22+
dcc.Dropdown(id="my-dd", options=["a", "b"], value="b"),
23+
]
24+
)
25+
26+
result = _call_tool(
27+
app,
28+
"get_dash_component",
29+
{
30+
"component_id": "my-dd",
31+
"property": "value",
32+
},
33+
)
34+
sc = result.structuredContent
35+
assert sc["component_id"] == "my-dd"
36+
assert sc["component_type"] == "Dropdown"
37+
assert "value" in sc["properties"]
38+
assert sc["properties"]["value"]["initial_value"] == "b"
39+
assert "options" not in sc["properties"]
40+
41+
42+
def test_mcpg003_returns_all_props_without_property():
43+
app = Dash(__name__)
44+
app.layout = html.Div(
45+
[
46+
dcc.Dropdown(id="my-dd", options=["a", "b"], value="b"),
47+
]
48+
)
49+
50+
result = _call_tool(
51+
app,
52+
"get_dash_component",
53+
{
54+
"component_id": "my-dd",
55+
},
56+
)
57+
sc = result.structuredContent
58+
assert "options" in sc["properties"]
59+
assert "value" in sc["properties"]
60+
assert sc["properties"]["value"]["initial_value"] == "b"
61+
62+
63+
def test_mcpg004_includes_label():
64+
app = Dash(__name__)
65+
app.layout = html.Div(
66+
[
67+
html.Label("Pick one", htmlFor="my-dd"),
68+
dcc.Dropdown(id="my-dd", options=["a", "b"], value="a"),
69+
]
70+
)
71+
72+
@app.callback(Output("my-dd", "value"), Input("my-dd", "options"))
73+
def noop(o):
74+
return "a"
75+
76+
result = _call_tool(
77+
app,
78+
"get_dash_component",
79+
{
80+
"component_id": "my-dd",
81+
},
82+
)
83+
sc = result.structuredContent
84+
assert sc["label"] == ["Pick one"]
85+
86+
87+
def test_mcpg005_includes_tool_references():
88+
app = Dash(__name__)
89+
app.layout = html.Div(
90+
[
91+
dcc.Dropdown(id="dd", options=["a", "b"], value="a"),
92+
html.Div(id="out"),
93+
]
94+
)
95+
96+
@app.callback(Output("out", "children"), Input("dd", "value"))
97+
def update(val):
98+
return val
99+
100+
result = _call_tool(
101+
app,
102+
"get_dash_component",
103+
{
104+
"component_id": "dd",
105+
"property": "value",
106+
},
107+
)
108+
prop_info = result.structuredContent["properties"]["value"]
109+
assert "update" in prop_info["input_to_tool"]
110+
111+
112+
def test_mcpg006_missing_id_returns_hint():
113+
app = _make_app()
114+
result = _call_tool(
115+
app,
116+
"get_dash_component",
117+
{
118+
"component_id": "nonexistent",
119+
"property": "value",
120+
},
121+
)
122+
text = result.content[0].text
123+
assert "nonexistent" in text
124+
assert "not found" in text
125+
assert "dash://components" in text
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Dynamic callback tools: MCP spec compliance, tool naming, output summaries.
2+
3+
Verifies that generated tools conform to the MCP 2025-11-25 specification
4+
and Dash-specific conventions. Focuses on shape/structure, tool-name
5+
sanitization, and ``_OUTPUT_SEMANTICS`` fallback summaries; input-schema
6+
values are covered by ``test_mcp_input_schemas``.
7+
8+
Reference: https://modelcontextprotocol.io/specification/2025-11-25/server/tools
9+
"""
10+
11+
import re
12+
from unittest.mock import Mock
13+
14+
from dash.mcp.primitives.tools.callback_adapter_collection import (
15+
CallbackAdapterCollection,
16+
)
17+
from dash.mcp.primitives.tools.descriptions import build_tool_description
18+
19+
from tests.unit.mcp.conftest import (
20+
_make_app,
21+
_tools_list,
22+
)
23+
24+
25+
# ---------------------------------------------------------------------------
26+
# Helpers
27+
# ---------------------------------------------------------------------------
28+
29+
_TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9_\-.]+$")
30+
31+
32+
def _adapter_with_outputs(outputs, docstring=None):
33+
adapter = Mock()
34+
adapter.outputs = outputs
35+
adapter._docstring = docstring
36+
return adapter
37+
38+
39+
def _out(comp_id, prop, comp_type=None):
40+
return {
41+
"id_and_prop": f"{comp_id}.{prop}",
42+
"component_id": comp_id,
43+
"property": prop,
44+
"component_type": comp_type,
45+
"initial_value": None,
46+
}
47+
48+
49+
# ---------------------------------------------------------------------------
50+
# MCP spec compliance — every generated tool must satisfy the spec
51+
# ---------------------------------------------------------------------------
52+
53+
54+
def test_mcptc001_all_tools_conform_to_mcp_spec():
55+
tools = _tools_list(_make_app())
56+
names = [t.name for t in tools]
57+
58+
assert len(names) == len(set(names)), f"Duplicate tool names: {names}"
59+
60+
for tool in tools:
61+
assert tool.name
62+
assert tool.inputSchema
63+
assert 1 <= len(tool.name) <= 128
64+
assert _TOOL_NAME_RE.match(tool.name), f"Invalid tool name: {tool.name}"
65+
66+
schema = tool.inputSchema
67+
assert isinstance(schema, dict)
68+
assert schema.get("type") == "object"
69+
assert isinstance(schema.get("properties", {}), dict)
70+
71+
required = set(schema.get("required", []))
72+
props = set(schema.get("properties", {}).keys())
73+
assert (
74+
required <= props
75+
), f"{tool.name}: required {required - props} not in properties"
76+
77+
78+
# ---------------------------------------------------------------------------
79+
# Built-in tools
80+
# ---------------------------------------------------------------------------
81+
82+
83+
def test_mcptc002_query_component_always_present():
84+
names = {t.name for t in _tools_list(_make_app())}
85+
assert "get_dash_component" in names
86+
87+
88+
def test_mcptc003_query_component_has_required_params():
89+
tool = next(t for t in _tools_list(_make_app()) if t.name == "get_dash_component")
90+
assert "component_id" in tool.inputSchema["properties"]
91+
assert "property" in tool.inputSchema["properties"]
92+
assert set(tool.inputSchema.get("required", [])) == {"component_id"}
93+
94+
95+
# ---------------------------------------------------------------------------
96+
# Tool-name sanitization (CallbackAdapterCollection._sanitize_name)
97+
# ---------------------------------------------------------------------------
98+
99+
100+
def test_mcptc004_sanitize_simple_name():
101+
assert CallbackAdapterCollection._sanitize_name("update_output") == "update_output"
102+
103+
104+
def test_mcptc005_sanitize_special_characters_replaced():
105+
assert CallbackAdapterCollection._sanitize_name("my-func.name") == "my_func_name"
106+
107+
108+
def test_mcptc006_sanitize_leading_digit():
109+
assert CallbackAdapterCollection._sanitize_name("123func") == "cb_123func"
110+
111+
112+
def test_mcptc007_sanitize_empty_name():
113+
assert CallbackAdapterCollection._sanitize_name("") == "unnamed_callback"
114+
115+
116+
def test_mcptc008_sanitize_consecutive_underscores_collapsed():
117+
assert CallbackAdapterCollection._sanitize_name("a---b___c") == "a_b_c"
118+
119+
120+
def test_mcptc009_sanitize_long_name_truncated_to_64_chars():
121+
result = CallbackAdapterCollection._sanitize_name("a" * 200)
122+
assert len(result) <= 64
123+
assert result[-8:].isalnum()
124+
125+
126+
def test_mcptc010_sanitize_long_name_uniqueness():
127+
result_a = CallbackAdapterCollection._sanitize_name("a" * 200)
128+
result_b = CallbackAdapterCollection._sanitize_name("b" * 200)
129+
assert result_a != result_b
130+
131+
132+
def test_mcptc011_sanitize_short_name_not_truncated():
133+
assert CallbackAdapterCollection._sanitize_name("short_name") == "short_name"
134+
135+
136+
# ---------------------------------------------------------------------------
137+
# Output semantic summary (``_OUTPUT_SEMANTICS`` in description_outputs.py).
138+
# Other description tests (docstring, output target, multi-output) are
139+
# covered by test_mcp_tools using real adapters.
140+
# ---------------------------------------------------------------------------
141+
142+
143+
def test_mcptc012_semantic_summary_with_component_type():
144+
adapter = _adapter_with_outputs([_out("my-graph", "figure", "Graph")])
145+
desc = build_tool_description(adapter)
146+
assert "Returns chart/visualization data" in desc
147+
148+
149+
def test_mcptc013_semantic_summary_fallback_by_property():
150+
adapter = _adapter_with_outputs([_out("unknown-id", "figure")])
151+
desc = build_tool_description(adapter)
152+
assert "Returns chart/visualization data" in desc

tests/unit/mcp/tools/test_tool_get_dash_component.py

Lines changed: 0 additions & 117 deletions
This file was deleted.

0 commit comments

Comments
 (0)