diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/conftest.py | 8 | ||||
| -rw-r--r-- | tests/test_compose_mml_tool.py | 32 | ||||
| -rw-r--r-- | tests/test_mcp_multimedia.py | 138 | ||||
| -rw-r--r-- | tests/test_mml_conformance.py | 6 | ||||
| -rw-r--r-- | tests/test_server.py | 9 | ||||
| -rw-r--r-- | tests/test_templates.py | 6 |
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", |
