summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/conftest.py8
-rw-r--r--tests/test_compose_mml_tool.py32
-rw-r--r--tests/test_mcp_multimedia.py138
-rw-r--r--tests/test_mml_conformance.py6
-rw-r--r--tests/test_server.py9
-rw-r--r--tests/test_templates.py6
6 files changed, 175 insertions, 24 deletions
diff --git a/tests/conftest.py b/tests/conftest.py
index 7359a06..5854b1d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -16,5 +16,9 @@ def tmp_output_dir(tmp_path, monkeypatch):
@pytest.fixture
def compose_mml(tmp_output_dir):
- """Return the compose_mml function with output dir patched."""
- return server_mod.compose_mml
+ """Return structured execution data with output dir patched."""
+
+ def execute(**kwargs):
+ return server_mod._execute_compose_mml(**kwargs).result
+
+ return execute
diff --git a/tests/test_compose_mml_tool.py b/tests/test_compose_mml_tool.py
index c33e737..4f904e4 100644
--- a/tests/test_compose_mml_tool.py
+++ b/tests/test_compose_mml_tool.py
@@ -1,7 +1,5 @@
"""Exhaustive tests for the compose_mml MCP tool."""
-from pathlib import Path
-
VALID_PPMCK = """#TITLE "Test"
A t120 l4 o4 v15 q2
c e g c
@@ -27,8 +25,10 @@ VALID_PYXEL = """0: T120 L4 O4 V100 @1
def test_compose_ppmck(compose_mml, tmp_output_dir):
result = compose_mml(action="compose", mml=VALID_PPMCK, mode="ppmck")
assert result["success"] is True
- assert result["wav_path"] is not None
- assert Path(result["wav_path"]).exists()
+ assert result["audio"]["mime_type"] == "audio/wav"
+ assert result["audio"]["size_bytes"] > 44
+ assert "wav_path" not in result
+ assert list(tmp_output_dir.rglob("output.wav"))
assert result["duration_sec"] > 0
assert result["note_sequence"] is not None
assert "validation" in result
@@ -39,8 +39,9 @@ def test_compose_ppmck(compose_mml, tmp_output_dir):
def test_compose_pyxel(compose_mml, tmp_output_dir):
result = compose_mml(action="compose", mml=VALID_PYXEL, mode="pyxel")
assert result["success"] is True
- assert result["wav_path"] is not None
- assert Path(result["wav_path"]).exists()
+ assert result["audio"]["mime_type"] == "audio/wav"
+ assert "wav_path" not in result
+ assert list(tmp_output_dir.rglob("output.wav"))
assert result["duration_sec"] > 0
@@ -95,7 +96,7 @@ def test_synthesis_templates_compose(compose_mml, tmp_output_dir):
f"{mode}/{template} synthesis failed: {result['validation']}"
)
assert result["duration_sec"] > 0
- assert Path(result["wav_path"]).exists()
+ assert result["audio"]["size_bytes"] > 44
def test_template_invalid_fallback(compose_mml):
@@ -131,7 +132,8 @@ def test_compose_unknown_mode(compose_mml):
def test_compose_with_syntax_error(compose_mml):
result = compose_mml(action="compose", mml="A t0\n c", mode="ppmck")
assert result["success"] is False
- assert result["wav_path"] is None
+ assert result["audio"] is None
+ assert "wav_path" not in result
assert result["duration_sec"] == 0
assert any(e["severity"] == "error" for e in result["validation"]["errors"])
@@ -168,8 +170,7 @@ def test_compose_normalize(compose_mml, tmp_output_dir):
on = compose_mml(action="compose", mml=base, mode="pyxel", normalize=True)
off = compose_mml(action="compose", mml=base, mode="pyxel", normalize=False)
assert on["success"] and off["success"]
- assert Path(on["wav_path"]).exists()
- assert Path(off["wav_path"]).exists()
+ assert len(list(tmp_output_dir.rglob("output.wav"))) == 2
def test_compose_creates_timestamped_output_directory(compose_mml, tmp_output_dir):
@@ -182,14 +183,9 @@ def test_compose_creates_timestamped_output_directory(compose_mml, tmp_output_di
assert result1["success"] is True
assert result2["success"] is True
- path1 = result1["wav_path"]
- path2 = result2["wav_path"]
- assert path1 is not None
- assert path2 is not None
- assert path1 != path2, "Two compose calls produced the same output directory"
-
- p1 = Path(path1)
- p2 = Path(path2)
+ paths = sorted(tmp_output_dir.rglob("output.wav"))
+ assert len(paths) == 2
+ p1, p2 = paths
assert p1.exists()
assert p2.exists()
assert p1.name == "output.wav"
diff --git a/tests/test_mcp_multimedia.py b/tests/test_mcp_multimedia.py
new file mode 100644
index 0000000..3baf155
--- /dev/null
+++ b/tests/test_mcp_multimedia.py
@@ -0,0 +1,138 @@
+"""Protocol-level tests for compose_mml multimedia results."""
+
+import asyncio
+import base64
+import json
+from unittest.mock import patch
+
+from fastmcp import Client
+from mcp.types import AudioContent, ResourceLink, TextContent
+
+from mml_composemusic_mcp.server import mcp
+
+VALID_MML = """A t120 l4 o4 v15 q2
+ c e g c
+"""
+
+
+async def _call_tool(arguments, *, raise_on_error=True):
+ async with Client(mcp) as client:
+ return await client.call_tool(
+ "compose_mml",
+ arguments,
+ raise_on_error=raise_on_error,
+ )
+
+
+def test_compose_returns_audio_content_without_exposing_path(tmp_output_dir):
+ result = asyncio.run(
+ _call_tool({"action": "compose", "mml": VALID_MML, "mode": "ppmck"})
+ )
+
+ assert result.is_error is False
+ assert result.structured_content["success"] is True
+ assert "wav_path" not in result.structured_content
+ assert result.structured_content["audio"] == {
+ "mime_type": "audio/wav",
+ "size_bytes": result.structured_content["audio"]["size_bytes"],
+ "sample_rate": 44100,
+ }
+
+ audio_blocks = [block for block in result.content if isinstance(block, AudioContent)]
+ assert len(audio_blocks) == 1
+ assert audio_blocks[0].mimeType == "audio/wav"
+ decoded = base64.b64decode(audio_blocks[0].data, validate=True)
+ assert len(decoded) > 100_000
+
+ wav_files = list(tmp_output_dir.rglob("output.wav"))
+ assert len(wav_files) == 1
+ assert decoded == wav_files[0].read_bytes()
+ assert result.structured_content["audio"]["size_bytes"] == len(decoded)
+ assert not any(isinstance(block, ResourceLink) for block in result.content)
+
+ text_blocks = [block for block in result.content if isinstance(block, TextContent)]
+ assert len(text_blocks) == 1
+ assert json.loads(text_blocks[0].text) == result.structured_content
+ assert "wav_path" not in text_blocks[0].text
+
+
+def test_compose_failure_is_mcp_tool_error_without_audio(tmp_output_dir):
+ result = asyncio.run(
+ _call_tool(
+ {"action": "compose", "mml": "A t0 c", "mode": "ppmck"},
+ raise_on_error=False,
+ )
+ )
+
+ assert result.is_error is True
+ assert result.structured_content["success"] is False
+ assert result.structured_content["audio"] is None
+ assert not any(isinstance(block, AudioContent) for block in result.content)
+ assert not list(tmp_output_dir.rglob("output.wav"))
+
+
+def test_invalid_validate_result_is_not_mcp_tool_error():
+ result = asyncio.run(
+ _call_tool({"action": "validate", "mml": "A t0 c", "mode": "ppmck"})
+ )
+
+ assert result.is_error is False
+ assert result.structured_content["valid"] is False
+ assert not any(isinstance(block, AudioContent) for block in result.content)
+
+
+def test_invalid_validate_mode_is_mcp_tool_error():
+ result = asyncio.run(
+ _call_tool(
+ {"action": "validate", "mml": VALID_MML, "mode": "unknown"},
+ raise_on_error=False,
+ )
+ )
+
+ assert result.is_error is True
+ assert result.structured_content["valid"] is False
+
+
+def test_unknown_action_is_mcp_tool_error():
+ result = asyncio.run(
+ _call_tool({"action": "unknown"}, raise_on_error=False)
+ )
+
+ assert result.is_error is True
+ assert result.structured_content["success"] is False
+
+
+def test_audio_serialization_failure_is_sanitized_tool_error(tmp_output_dir):
+ with patch(
+ "mml_composemusic_mcp.server.Audio.to_audio_content",
+ side_effect=OSError("C:\\private\\output.wav"),
+ ):
+ result = asyncio.run(
+ _call_tool(
+ {"action": "compose", "mml": VALID_MML, "mode": "ppmck"},
+ raise_on_error=False,
+ )
+ )
+
+ assert result.is_error is True
+ assert result.structured_content["success"] is False
+ assert result.structured_content["audio"] is None
+ assert "C:\\private" not in json.dumps(result.structured_content)
+ assert not any(isinstance(block, AudioContent) for block in result.content)
+
+
+def test_unexpected_execution_failure_is_sanitized_tool_error():
+ with patch(
+ "mml_composemusic_mcp.server._execute_compose_mml",
+ side_effect=OSError("C:\\private\\source.mml"),
+ ):
+ result = asyncio.run(
+ _call_tool(
+ {"action": "compose", "mml": VALID_MML, "mode": "ppmck"},
+ raise_on_error=False,
+ )
+ )
+
+ assert result.is_error is True
+ assert result.structured_content["success"] is False
+ assert "C:\\private" not in json.dumps(result.structured_content)
diff --git a/tests/test_mml_conformance.py b/tests/test_mml_conformance.py
index 82ec807..8c3822e 100644
--- a/tests/test_mml_conformance.py
+++ b/tests/test_mml_conformance.py
@@ -41,7 +41,11 @@ from mml_composemusic_mcp.parser_ppmck import parse_ppmck
from mml_composemusic_mcp.parser_pyxel import parse_pyxel
from mml_composemusic_mcp.semantic_ppmck import analyze_ppmck
from mml_composemusic_mcp.semantic_pyxel import analyze_pyxel
-from mml_composemusic_mcp.server import _parse_mml, compose_mml
+from mml_composemusic_mcp.server import _execute_compose_mml, _parse_mml
+
+
+def compose_mml(**kwargs):
+ return _execute_compose_mml(**kwargs).result
def parse_pipeline(source: str, mode: str):
diff --git a/tests/test_server.py b/tests/test_server.py
index 533c88b..f858e0f 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -1,6 +1,10 @@
"""Tests for MCP server compose_mml tool."""
-from mml_composemusic_mcp.server import compose_mml
+from mml_composemusic_mcp.server import _execute_compose_mml
+
+
+def compose_mml(**kwargs):
+ return _execute_compose_mml(**kwargs).result
PPMCK_MML = """#TITLE "Test"
A t120 l4 o4 v15 q2
@@ -11,7 +15,8 @@ A t120 l4 o4 v15 q2
def test_compose_ppmck():
result = compose_mml(action="compose", mml=PPMCK_MML, mode="ppmck")
assert result["success"] is True
- assert result["wav_path"] is not None
+ assert result["audio"]["mime_type"] == "audio/wav"
+ assert "wav_path" not in result
assert result["duration_sec"] > 0
diff --git a/tests/test_templates.py b/tests/test_templates.py
index 5fcf893..d61f98a 100644
--- a/tests/test_templates.py
+++ b/tests/test_templates.py
@@ -2,9 +2,13 @@
import pytest
-from mml_composemusic_mcp.server import compose_mml
+from mml_composemusic_mcp.server import _execute_compose_mml
from mml_composemusic_mcp.templates import DESCRIPTIONS, TEMPLATES, get_template
+
+def compose_mml(**kwargs):
+ return _execute_compose_mml(**kwargs).result
+
TEMPLATE_NAMES = (
"basic",
"melody",