"""End-to-end smoke for chart-tools: spec → PNG file on disk. Tests run with ``asyncio_mode=auto`` (see tools/pyproject.toml), so any ``async def test_*`` is automatically run via the pytest-asyncio plugin. chart_render is async because FastMCP runs tool handlers inside the running event loop; we await it the same way the framework does. """ from __future__ import annotations import struct from pathlib import Path import pytest EXPECTED_TOOLS = {"chart_render"} def test_register_chart_tools_lands_all(mcp): from chart_tools import register_chart_tools names = register_chart_tools(mcp) assert set(names) == EXPECTED_TOOLS, ( f"missing: {EXPECTED_TOOLS - set(names)}, extra: {set(names) - EXPECTED_TOOLS}" ) def test_all_tools_have_chart_prefix(mcp): from chart_tools import register_chart_tools for n in register_chart_tools(mcp): assert n.startswith("chart_"), f"{n!r} missing chart_ prefix" @pytest.fixture def chart_tool(mcp, tmp_path, monkeypatch): monkeypatch.setenv("HIVE_HOME", str(tmp_path)) from chart_tools.tools import register_tools register_tools(mcp) return mcp._tool_manager._tools["chart_render"].fn def _is_png(b: bytes) -> bool: """Verify the magic bytes + at least one IHDR chunk.""" return b[:8] == b"\x89PNG\r\n\x1a\n" and b"IHDR" in b[:32] def _png_dims(path: Path) -> tuple[int, int]: """Read width/height from the IHDR chunk so we can assert the screenshot honored the requested dpi/scale.""" data = path.read_bytes() # IHDR is at offset 16 (after 8-byte signature + 4-byte length + 4-byte 'IHDR') width = struct.unpack(">I", data[16:20])[0] height = struct.unpack(">I", data[20:24])[0] return width, height async def test_render_echarts_bar_chart(chart_tool, tmp_path): """The flagship test: agent calls chart_render with an ECharts spec → file lands on disk, returns a non-empty envelope including the spec echo so the chat can render it live.""" spec = { "title": {"text": "Smoke test"}, "xAxis": {"type": "category", "data": ["A", "B", "C"]}, "yAxis": {"type": "value"}, "series": [{"type": "bar", "data": [12, 24, 6]}], } result = await chart_tool( kind="echarts", spec=spec, title="smoke", width=600, height=400, dpi=96, # keep test fast: 1x scale ) assert "error" not in result, result assert result["kind"] == "echarts" assert result["spec"] == spec, "spec must be echoed back so the chat can re-render" assert result["width"] == 600 and result["height"] == 400 assert result["bytes"] > 1000, "PNG should be at least 1KB" path = Path(result["file_path"]) assert path.exists(), f"file not written: {path}" assert _is_png(path.read_bytes()), "file is not a valid PNG" w, h = _png_dims(path) # At dpi=96 (1x), the PNG should be roughly viewport-sized. assert 500 <= w <= 700, f"unexpected PNG width {w}" assert 300 <= h <= 500, f"unexpected PNG height {h}" # File should land under HIVE_HOME/charts/ assert path.parent.name == "charts" assert "smoke" in path.name async def test_render_echarts_invalid_kind_returns_error(chart_tool): result = await chart_tool(kind="bogus", spec={}, title="x") assert "error" in result assert result["kind"] == "bogus" # echoed for the chat to surface async def test_render_echarts_accepts_string_spec(chart_tool): """Regression: agent sometimes passes the spec as a JSON STRING instead of a dict (the actual failure shown to the user on 2026-05-01: 'Cannot create property \"series\" on string ...'). chart_render must parse string specs transparently. """ import json as _json spec_dict = { "title": {"text": "String-spec regression"}, "xAxis": {"type": "category", "data": ["A", "B", "C"]}, "yAxis": {"type": "value"}, "series": [{"type": "bar", "data": [1, 2, 3]}], } result = await chart_tool( kind="echarts", spec=_json.dumps(spec_dict), # ← STRING not dict title="string-spec", width=600, height=400, dpi=96, ) assert "error" not in result, result assert result["bytes"] > 1000 # Spec should be echoed back as the parsed dict, not the original string. assert isinstance(result["spec"], dict) assert result["spec"] == spec_dict async def test_render_mermaid_flowchart(chart_tool, tmp_path): """Mermaid path: agent passes raw mermaid source as the spec str.""" src = """flowchart LR A[Start] --> B{ok?} B -- yes --> C[Done] B -- no --> D[Retry] """ result = await chart_tool( kind="mermaid", spec=src, title="flow", width=600, height=400, dpi=96, ) assert "error" not in result, result assert result["spec"] == src path = Path(result["file_path"]) assert path.exists() and _is_png(path.read_bytes())