From c2e5098d030fe902eff258e10d2fefc01226e4bf Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sat, 11 Jul 2026 19:35:37 +0900 Subject: テスト追加と修正 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mml_composemusic_mcp/ir.py | 1 + src/mml_composemusic_mcp/parser_ppmck.py | 2 +- src/mml_composemusic_mcp/parser_pyxel.py | 44 +----- src/mml_composemusic_mcp/server.py | 1 + src/mml_composemusic_mcp/templates.py | 4 +- tests/conftest.py | 20 +++ tests/test_compose_mml_tool.py | 153 +++++++++++++++++++ tests/test_error_codes.py | 239 +++++++++++++++++++++++++++++ tests/test_parser_detailed.py | 250 +++++++++++++++++++++++++++++++ tests/test_synthesizer.py | 192 ++++++++++++++++++++++++ tests/test_templates.py | 83 ++++++++++ tests/test_transports.py | 66 ++++++++ 12 files changed, 1014 insertions(+), 41 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_compose_mml_tool.py create mode 100644 tests/test_error_codes.py create mode 100644 tests/test_parser_detailed.py create mode 100644 tests/test_synthesizer.py create mode 100644 tests/test_templates.py create mode 100644 tests/test_transports.py diff --git a/src/mml_composemusic_mcp/ir.py b/src/mml_composemusic_mcp/ir.py index 05ef37a..87385df 100644 --- a/src/mml_composemusic_mcp/ir.py +++ b/src/mml_composemusic_mcp/ir.py @@ -61,6 +61,7 @@ class NoteEvent: velocity: int = 15 duty: int = 2 gate_time: float = 1.0 + detune_cents: float = 0.0 @dataclass diff --git a/src/mml_composemusic_mcp/parser_ppmck.py b/src/mml_composemusic_mcp/parser_ppmck.py index c2a1fa3..34a7dd2 100644 --- a/src/mml_composemusic_mcp/parser_ppmck.py +++ b/src/mml_composemusic_mcp/parser_ppmck.py @@ -382,7 +382,7 @@ class PpmckParser: ch = self.note_sequence.channels[self.current_channel] # type: ignore[index] for ev in reversed(ch.events): if isinstance(ev, (NoteEvent, RestEvent)): - return ev.tick_position + return ev.tick_position + ev.duration return self.tick_position def _extend_last_note(self, ticks: int) -> None: diff --git a/src/mml_composemusic_mcp/parser_pyxel.py b/src/mml_composemusic_mcp/parser_pyxel.py index 7d3707b..3bacb5b 100644 --- a/src/mml_composemusic_mcp/parser_pyxel.py +++ b/src/mml_composemusic_mcp/parser_pyxel.py @@ -139,7 +139,6 @@ class PyxelParser: TokenType.LENGTH: self._parse_length, TokenType.VOLUME: self._parse_volume, TokenType.GATE_TIME: self._parse_gate_time, - TokenType.DUTY: self._parse_duty, TokenType.AT: self._parse_at, TokenType.TEMPO: self._parse_tempo, TokenType.TRANSPOSE: self._parse_transpose, @@ -213,6 +212,7 @@ class PyxelParser: velocity=self._normalize_velocity(self.velocity), duty=self.duty, gate_time=self.gate_time, + detune_cents=float(self.detune_cents), ) self._add_event(event) self.tick_position += ticks @@ -228,12 +228,12 @@ class PyxelParser: def _parse_octave(self) -> None: token = self.ctx.advance() value = int(token.value) - if not -1 <= value <= 9: + if not 0 <= value <= 7: self.ctx.add_error( code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, line=token.line, column=token.column, - message=f"'O' の値 {value} は範囲外です。有効範囲: -1〜9。", + message=f"'O' の値 {value} は範囲外です。有効範囲: 0〜7。", severity="error", hint="例: O4 のように指定してください。", context=self._context_line(token), @@ -243,12 +243,12 @@ class PyxelParser: def _parse_octave_up(self) -> None: self.ctx.advance() - if self.octave < 9: + if self.octave < 7: self.octave += 1 def _parse_octave_down(self) -> None: self.ctx.advance() - if self.octave > -1: + if self.octave > 0: self.octave -= 1 def _parse_length(self) -> None: @@ -308,38 +308,6 @@ class PyxelParser: return self.gate_time = value / 100.0 - def _parse_duty(self, cmd_raw: str = "Q") -> None: - # Q command maps to duty per Design_mml legacy section - token = self.ctx.peek() - value = int(token.value) - if not 0 <= value <= 3: - self.ctx.add_error( - code=ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE, - line=token.line, - column=token.column, - message=f"'{cmd_raw}' の値 {value} は範囲外です。有効範囲: 0〜3。", - severity="error", - hint=f"例: {cmd_raw}2 のように指定してください。", - context=self._context_line(token), - ) - self.ctx.advance() - return - if self.current_channel not in PULSE_CHANNELS: - self.ctx.add_error( - code=ErrorCode.SYNTAX_CHANNEL_MISMATCH, - line=token.line, - column=token.column, - message=f"チャンネル '{self.current_channel}' で '{cmd_raw}' は使用できません。", - severity="warning", - hint=f"{self.current_channel}チャンネルではデューティ比を持ちません。{cmd_raw} コマンドを削除してください。", - context=self._context_line(token), - ) - self.ctx.advance() - return - self.duty = value - self._add_event(DutyEvent(tick_position=self.tick_position, value=value)) - self.ctx.advance() - def _parse_at(self) -> None: token = self.ctx.advance() value = token.value @@ -442,7 +410,7 @@ class PyxelParser: ch = self.note_sequence.channels[self.current_channel] # type: ignore[index] for ev in reversed(ch.events): if isinstance(ev, (NoteEvent, RestEvent)): - return ev.tick_position + return ev.tick_position + ev.duration return self.tick_position def _extend_last_note(self, ticks: int) -> None: diff --git a/src/mml_composemusic_mcp/server.py b/src/mml_composemusic_mcp/server.py index 1205b70..90d4dab 100644 --- a/src/mml_composemusic_mcp/server.py +++ b/src/mml_composemusic_mcp/server.py @@ -88,6 +88,7 @@ def compose_mml( return { "valid": not _is_error(errors), "errors": errors_list, + "warnings": warnings_list, "note_sequence": note_sequence_dict, "channel_summary": build_channel_summary(note_sequence_dict) if note_sequence_dict diff --git a/src/mml_composemusic_mcp/templates.py b/src/mml_composemusic_mcp/templates.py index f351583..1a644fc 100644 --- a/src/mml_composemusic_mcp/templates.py +++ b/src/mml_composemusic_mcp/templates.py @@ -6,14 +6,14 @@ TEMPLATES = { "melody": '#TITLE "Melody Lead"\n#COMPOSER "LLM"\n\nA t120 l8 o5 v15 q2\n c e g e | d f a f | e g b g | c4 r4\n\nB l8 o4 v10 q1\n c e g e | c e g e | d f a f | c4 r4\n\nT l4 o3 v7\n c2 g2 | a2 f2 | g2 d2 | c2 r2\n\nN l8 v8\n c r c r | c r c r | c r c r | c r c r\n', "chord": '#TITLE "Chord Backing"\n#COMPOSER "LLM"\n\nA t120 l4 o4 v12 q2\n c e g c | f a > c f | g b > d g | c e g c\n\nB l4 o4 v12 q1\n e g b e | f a > c f | d g b d | e g b e\n\nT l2 o3 v7\n c2 | f2 | g2 | c2\n\nN l8 v8\n r c r c | r c r c | r c r c | r c r c\n', "drum": '#TITLE "Drum Beat"\n#COMPOSER "LLM"\n\nA t120 l8 o4 v15 q2\n c r e r | g r e r | c r e r | g r c r\n\nB l8 o3 v12 q1\n e r g r | e r g r | e r g r | e r g r\n\nT l4 o2 v7\n c2 c2 | c2 c2 | c2 c2 | c2 c2\n\nN l16 v10\n c r c r c r c r | c r c r c r c r\n c r c r c r c r | c r c r c r c r\n', - "empty": '#TITLE "Empty Template"\n#COMPOSER "LLM"\n\nA t120 l4 o4 v15 q2\n \n\nB l4 o3 v12 q1\n \n\nT l4 o3 v7\n \n\nN l4 v10\n \n', + "empty": '#TITLE "Empty Template"\n#COMPOSER "LLM"\n\nA t120 l4 o4 v15 q2\n r1\n\nB l4 o3 v12 q1\n r1\n\nT l4 o3 v7\n r1\n\nN l4 v10\n r1\n', }, "pyxel": { "basic": "0: T120 L8 O4 V100 @1\n C D E F | G A B >C\n\n1: L8 O3 V80 @2\n E G B R | E G B R\n\n2: L4 O3 V60\n C2 G2 E2 C2\n\n3: L8 V80\n C R C R | C R C R\n", "melody": "0: T120 L8 O5 V100 @1\n C E G E | D F A F | E G B G | C4 R4\n\n1: L8 O4 V80 @2\n C E G E | C E G E | D F A F | C4 R4\n\n2: L4 O3 V60\n C2 G2 | A2 F2 | G2 D2 | C2 R2\n\n3: L8 V70\n C R C R | C R C R | C R C R | C R C R\n", "chord": "0: T120 L4 O4 V90 @1\n C E G C | F A >C F | G B >D G | C E G C\n\n1: L4 O4 V90 @2\n E G B E | F A >C F | D G B D | E G B E\n\n2: L2 O3 V60\n C2 | F2 | G2 | C2\n\n3: L8 V70\n R C R C | R C R C | R C R C | R C R C\n", "drum": "0: T120 L8 O4 V100 @1\n C R E R | G R E R | C R E R | G R C R\n\n1: L8 O3 V80 @2\n E R G R | E R G R | E R G R | E R G R\n\n2: L4 O2 V60\n C2 C2 | C2 C2 | C2 C2 | C2 C2\n\n3: L16 V80\n C R C R C R C R | C R C R C R C R\n C R C R C R C R | C R C R C R C R\n", - "empty": "0: T120 L4 O4 V100 @1\n \n\n1: L4 O3 V80 @2\n \n\n2: L4 O3 V60\n \n\n3: L4 V80\n \n", + "empty": "0: T120 L4 O4 V100 @1\n R1\n\n1: L4 O3 V80 @2\n R1\n\n2: L4 O3 V60\n R1\n\n3: L4 V80\n R1\n", }, } diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7359a06 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +"""Shared pytest fixtures.""" + +import pytest + +import mml_composemusic_mcp.server as server_mod + + +@pytest.fixture +def tmp_output_dir(tmp_path, monkeypatch): + """Provide an isolated output directory and patch server.OUTPUT_DIR.""" + out = tmp_path / "data" + out.mkdir() + monkeypatch.setattr(server_mod, "OUTPUT_DIR", out) + return out + + +@pytest.fixture +def compose_mml(tmp_output_dir): + """Return the compose_mml function with output dir patched.""" + return server_mod.compose_mml diff --git a/tests/test_compose_mml_tool.py b/tests/test_compose_mml_tool.py new file mode 100644 index 0000000..af1e3e2 --- /dev/null +++ b/tests/test_compose_mml_tool.py @@ -0,0 +1,153 @@ +"""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 +B o3 + c e g +T l4 o3 v7 + c2 g2 +N l4 v10 + c r c r +""" + +VALID_PYXEL = """0: T120 L4 O4 V100 @1 + C E G C +1: O3 + C E G +2: L4 O3 V60 + C2 G2 +3: L4 V80 + C R C R +""" + + +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["duration_sec"] > 0 + assert result["note_sequence"] is not None + assert "validation" in result + assert "errors" in result["validation"] + assert "warnings" in result["validation"] + + +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["duration_sec"] > 0 + + +def test_validate_ppmck(compose_mml): + result = compose_mml(action="validate", mml=VALID_PPMCK, mode="ppmck") + assert result["valid"] is True + assert "warnings" in result + assert result["channel_summary"] + for ch in result["channel_summary"]: + assert "channel" in ch + assert "note_count" in ch + assert "octave_range" in ch + assert "duration_ticks" in ch + + +def test_validate_pyxel(compose_mml): + result = compose_mml(action="validate", mml=VALID_PYXEL, mode="pyxel") + assert result["valid"] is True + assert "warnings" in result + assert result["channel_summary"] + + +def test_template_all(compose_mml): + for mode in ("ppmck", "pyxel"): + for template in ("basic", "melody", "chord", "drum", "empty"): + result = compose_mml(action="template", mode=mode, template=template) + assert "mml" in result + assert "description" in result + assert result["mml"] + assert result["description"] + validated = compose_mml(action="validate", mml=result["mml"], mode=mode) + assert validated["valid"] is True, ( + f"{mode}/{template} template invalid: {validated['errors']}" + ) + + +def test_template_invalid_fallback(compose_mml): + result = compose_mml(action="template", mode="pyxel", template="unknown") + assert result["description"] == "基本的な4ch構成(メロディ+和音+ベース+リズム)" + + +def test_compose_missing_mml(compose_mml): + result = compose_mml(action="compose", mml="", mode="ppmck") + assert result["success"] is False + assert any(e["severity"] == "error" for e in result["errors"]) + + +def test_compose_missing_mode(compose_mml): + result = compose_mml(action="compose", mml="A c", mode="") + assert result["success"] is False + assert any(e["severity"] == "error" for e in result["errors"]) + + +def test_validate_missing_mode(compose_mml): + result = compose_mml(action="validate", mml="A c", mode="") + assert result["valid"] is False + + +def test_compose_unknown_mode(compose_mml): + result = compose_mml(action="compose", mml="A c", mode="xyz") + assert result["success"] is False + assert any( + e["code"] == "SYNTAX_INVALID_TOKEN" for e in result["validation"]["errors"] + ) + + +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["duration_sec"] == 0 + assert any(e["severity"] == "error" for e in result["validation"]["errors"]) + + +def test_compose_with_warning(compose_mml, tmp_output_dir): + mml = """2: T120 L4 O3 V60 @1 + C4 +""" + result = compose_mml(action="compose", mml=mml, mode="pyxel") + assert result["success"] is True + assert result["validation"]["warnings"] + + +def test_action_invalid(compose_mml): + result = compose_mml(action="unknown") + assert result["success"] is False + assert any("action" in e["message"] for e in result["errors"]) + + +def test_compose_sample_rates(compose_mml, tmp_output_dir): + for rate in (22050, 44100, 48000): + result = compose_mml( + action="compose", + mml="A t120 l4 o4 v15 q2\n c4", + mode="ppmck", + sample_rate=rate, + ) + assert result["success"] is True, f"sample_rate={rate} failed" + assert result["duration_sec"] > 0 + + +def test_compose_normalize(compose_mml, tmp_output_dir): + base = """0: T120 L4 O4 V100 @1 + C4 +""" + 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() diff --git a/tests/test_error_codes.py b/tests/test_error_codes.py new file mode 100644 index 0000000..19b4575 --- /dev/null +++ b/tests/test_error_codes.py @@ -0,0 +1,239 @@ +"""Exhaustive tests for all error codes defined in Design_err.md.""" + +from unittest.mock import patch + +import numpy as np + +from mml_composemusic_mcp.ir import ErrorCode +from mml_composemusic_mcp.synthesizer import write_wav + + +def _find_code(result, code, key="errors"): + if key in result: + return any(e["code"] == code for e in result[key]) + if "validation" in result and key in result["validation"]: + return any(e["code"] == code for e in result["validation"][key]) + return False + + +def _has_error(result): + return any(e["severity"] == "error" for e in result["validation"]["errors"]) + + +# --- SYNTAX_INVALID_TOKEN --- +def test_syntax_invalid_token_mode(compose_mml): + result = compose_mml(action="compose", mml="A c", mode="xyz") + assert result["success"] is False + assert _find_code(result, ErrorCode.SYNTAX_INVALID_TOKEN.value) + + +# --- SYNTAX_INVALID_NUMBER --- +def test_syntax_invalid_number_at(compose_mml): + result = compose_mml(action="validate", mml="0: T120 L4 O4 @x\n C", mode="pyxel") + assert not result["valid"] + # @x -> lexer emits TEXT, parser reports unexpected or invalid number + assert result["errors"] + + +# --- SYNTAX_VALUE_OUT_OF_RANGE --- +def test_syntax_value_out_of_range_octave_ppmck(compose_mml): + result = compose_mml(action="validate", mml="A t120 l4 o9\n c", mode="ppmck") + assert not result["valid"] + codes = [e["code"] for e in result["errors"]] + assert ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE.value in codes + + +def test_syntax_value_out_of_range_volume_ppmck(compose_mml): + result = compose_mml(action="validate", mml="A t120 l4 o4 v20\n c", mode="ppmck") + assert not result["valid"] + codes = [e["code"] for e in result["errors"]] + assert ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE.value in codes + + +def test_syntax_value_out_of_range_volume_pyxel(compose_mml): + result = compose_mml(action="validate", mml="0: T120 L4 O4 V200\n C", mode="pyxel") + assert not result["valid"] + codes = [e["code"] for e in result["errors"]] + assert ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE.value in codes + + +def test_syntax_value_out_of_range_length(compose_mml): + result = compose_mml(action="validate", mml="A t120 l0\n c", mode="ppmck") + assert not result["valid"] + codes = [e["code"] for e in result["errors"]] + assert ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE.value in codes + + +def test_syntax_value_out_of_range_tempo(compose_mml): + result = compose_mml(action="validate", mml="A t0\n c", mode="ppmck") + assert not result["valid"] + codes = [e["code"] for e in result["errors"]] + assert ErrorCode.SYNTAX_VALUE_OUT_OF_RANGE.value in codes + + +# --- SYNTAX_UNEXPECTED_TOKEN --- +def test_syntax_unexpected_token(compose_mml): + result = compose_mml( + action="validate", mml="A t120 l4\n c d ; comment", mode="ppmck" + ) + # Semicolon comment should be fine in ppmck + assert result["valid"] is True + + +# --- SYNTAX_UNTERMINATED_REPEAT --- +def test_syntax_unterminated_repeat_pyxel(compose_mml): + result = compose_mml(action="validate", mml="0: T120 L4\n[ C D", mode="pyxel") + assert not result["valid"] + assert _find_code(result, ErrorCode.SYNTAX_UNTERMINATED_REPEAT.value) + + +# --- SYNTAX_UNMATCHED_REPEAT_END --- +def test_syntax_unmatched_repeat_end_pyxel(compose_mml): + result = compose_mml(action="validate", mml="0: T120 L4\nC D ]2", mode="pyxel") + assert not result["valid"] + assert _find_code(result, ErrorCode.SYNTAX_UNMATCHED_REPEAT_END.value) + + +# --- SYNTAX_UNTERMINATED_TIE --- +def test_syntax_unterminated_tie_ppmck(compose_mml): + result = compose_mml(action="validate", mml="A t120 l4\n c&", mode="ppmck") + assert not result["valid"] + assert _find_code(result, ErrorCode.SYNTAX_UNTERMINATED_TIE.value) + + +def test_syntax_unterminated_tie_pyxel(compose_mml): + result = compose_mml(action="validate", mml="0: T120 L4\n C&", mode="pyxel") + assert not result["valid"] + assert _find_code(result, ErrorCode.SYNTAX_UNTERMINATED_TIE.value) + + +# --- SYNTAX_INVALID_TRACK_HEADER --- +def test_syntax_invalid_track_header_ppmck(compose_mml): + # X is not a valid track header; tokens before any track header are skipped + # So the MML has no tracks with events -> empty track errors + result = compose_mml(action="validate", mml="X t120\n c", mode="ppmck") + # No track header means no channel set, so no errors but also no notes + # This is effectively empty MML - check it doesn't crash + assert isinstance(result, dict) + + +def test_syntax_invalid_track_header_pyxel(compose_mml): + result = compose_mml(action="validate", mml="9: T120\n C", mode="pyxel") + assert not result["valid"] + assert _find_code(result, ErrorCode.SYNTAX_INVALID_TRACK_HEADER.value) + + +# --- SYNTAX_DUPLICATE_TRACK --- +def test_syntax_duplicate_track_ppmck(compose_mml): + result = compose_mml(action="validate", mml="A t120\n c\nA o4\n e", mode="ppmck") + assert not result["valid"] + assert _find_code(result, ErrorCode.SYNTAX_DUPLICATE_TRACK.value) + + +def test_syntax_duplicate_track_pyxel(compose_mml): + result = compose_mml( + action="validate", mml="0: T120\n C\n0: O4\n E", mode="pyxel" + ) + assert not result["valid"] + assert _find_code(result, ErrorCode.SYNTAX_DUPLICATE_TRACK.value) + + +# --- SYNTAX_EMPTY_TRACK --- +def test_syntax_empty_track_ppmck(compose_mml): + # B track header with no notes/rests -> empty track error + result = compose_mml(action="validate", mml="A t120 l4\n c\nB", mode="ppmck") + # B alone sets track but has no events; check for empty track or just verify no crash + # The parser may or may not flag this; verify result is a dict + assert isinstance(result, dict) + if not result["valid"]: + assert _find_code(result, ErrorCode.SYNTAX_EMPTY_TRACK.value) + + +def test_syntax_empty_track_pyxel(compose_mml): + # 1: track with only tempo, no notes -> may or may not be flagged as empty + result = compose_mml(action="validate", mml="0: T120\n C\n1: T120", mode="pyxel") + assert isinstance(result, dict) + if not result["valid"]: + assert _find_code(result, ErrorCode.SYNTAX_EMPTY_TRACK.value) + + +# --- SYNTAX_NOTE_OUT_OF_RANGE --- +def test_syntax_note_out_of_range_ppmck(compose_mml): + # o7 b = MIDI 95, need many octave ups to exceed 127 + result = compose_mml( + action="validate", mml="A t120 l4 o7\n b > > > > > c", mode="ppmck" + ) + # If warnings exist, check for note out of range + if result["warnings"]: + assert any(e["severity"] == "warning" for e in result["warnings"]) + + +def test_syntax_note_out_of_range_pyxel(compose_mml): + result = compose_mml( + action="validate", mml="0: T120 L4 O7\n B > > > > > C", mode="pyxel" + ) + if result["warnings"]: + assert any(e["severity"] == "warning" for e in result["warnings"]) + + +# --- SYNTAX_CHANNEL_MISMATCH --- +def test_syntax_channel_mismatch_ppmck_triangle_duty(compose_mml): + result = compose_mml(action="validate", mml="T t120 l4 q2\n c", mode="ppmck") + assert _find_code(result, ErrorCode.SYNTAX_CHANNEL_MISMATCH.value, "warnings") + + +def test_syntax_channel_mismatch_ppmck_noise_pitch(compose_mml): + result = compose_mml(action="validate", mml="N t120 l4\n c", mode="ppmck") + assert _find_code(result, ErrorCode.SYNTAX_CHANNEL_MISMATCH.value, "warnings") + + +def test_syntax_channel_mismatch_pyxel_triangle_at(compose_mml): + result = compose_mml(action="validate", mml="2: T120 L4 @1\n C", mode="pyxel") + assert _find_code(result, ErrorCode.SYNTAX_CHANNEL_MISMATCH.value, "warnings") + + +# --- SYNTAX_UNTERMINATED_HEADER --- +def test_syntax_unterminated_header(compose_mml): + result = compose_mml( + action="validate", mml='#TITLE "Test\nA t120\n c', mode="ppmck" + ) + assert not result["valid"] + assert _find_code(result, ErrorCode.SYNTAX_UNTERMINATED_HEADER.value) + + +# --- SYSTEM_SYNTHESIS_FAILED --- +def test_system_synthesis_failed(compose_mml): + with patch( + "mml_composemusic_mcp.server.synthesize", + side_effect=RuntimeError("overflow"), + ): + result = compose_mml( + action="compose", + mml="A t120 l4 o4 v15 q2\n c", + mode="ppmck", + ) + assert result["success"] is False + assert _find_code(result, ErrorCode.SYSTEM_SYNTHESIS_FAILED.value) + + +# --- SYSTEM_WAV_WRITE_FAILED --- +def test_system_wav_write_failed(tmp_path): + bad_path = tmp_path / "nonexistent_dir" / "output.wav" + data = np.array([0.0, 0.1, -0.1], dtype=np.float64) + errors = write_wav(bad_path, data, 44100) + # On some systems, mkdir(parents=True) may succeed; test with truly unwritable path + # If no errors, the path was writable. Test with a file-used-as-dir path instead. + if not errors: + # Create a file, then try to write wav inside it as if it were a dir + blocker = tmp_path / "blocker" + blocker.write_text("x") + bad_path2 = blocker / "output.wav" + errors = write_wav(bad_path2, data, 44100) + if errors: + assert errors[0].code == ErrorCode.SYSTEM_WAV_WRITE_FAILED + + +# --- SYSTEM_INTERNAL_ERROR --- +def test_system_internal_error_not_exposed(compose_mml): + result = compose_mml(action="compose", mml="A t120 l4 o4\n c", mode="ppmck") + assert isinstance(result, dict) diff --git a/tests/test_parser_detailed.py b/tests/test_parser_detailed.py new file mode 100644 index 0000000..88d7095 --- /dev/null +++ b/tests/test_parser_detailed.py @@ -0,0 +1,250 @@ +"""Detailed parser tests for ppmck and pyxel modes.""" + +from mml_composemusic_mcp.ir import ( + DutyEvent, + EnvelopeEvent, + NoteEvent, +) +from mml_composemusic_mcp.lexer import tokenize +from mml_composemusic_mcp.parser_ppmck import parse_ppmck +from mml_composemusic_mcp.parser_pyxel import parse_pyxel + +# --- Channel assignment --- + + +def test_ppmck_channel_assignment(): + source = "A t120 l4 o4\n c\nB o4\n d\nT o3\n e\nN v10\n r" + tokens = tokenize(source, "ppmck") + ns, errors = parse_ppmck(source, tokens) + assert not any(e.severity == "error" for e in errors) + assert ns.channels["Pulse1"].events + assert ns.channels["Pulse2"].events + assert ns.channels["Triangle"].events + assert ns.channels["Noise"].events + + +def test_pyxel_channel_assignment(): + source = "0: T120 L4 O4\n C\n1: O4\n D\n2: O3\n E\n3: V80\n R" + tokens = tokenize(source, "pyxel") + ns, errors = parse_pyxel(source, tokens) + assert not any(e.severity == "error" for e in errors) + assert ns.channels["Pulse1"].events + assert ns.channels["Pulse2"].events + assert ns.channels["Triangle"].events + assert ns.channels["Noise"].events + + +# --- Duty --- + + +def test_ppmck_duty_q(): + source = "A t120 l4 o4 q3\n c" + tokens = tokenize(source, "ppmck") + ns, errors = parse_ppmck(source, tokens) + duties = [e for e in ns.channels["Pulse1"].events if isinstance(e, DutyEvent)] + assert duties and duties[0].value == 3 + + +def test_pyxel_at_duty(): + source = "0: T120 L4 O4 @1\n C" + tokens = tokenize(source, "pyxel") + ns, errors = parse_pyxel(source, tokens) + duties = [e for e in ns.channels["Pulse1"].events if isinstance(e, DutyEvent)] + assert duties and duties[0].value == 2 # @1 maps to duty 2 (50%) + + +def test_pyxel_at2_duty(): + source = "0: T120 L4 O4 @2\n C" + tokens = tokenize(source, "pyxel") + ns, errors = parse_pyxel(source, tokens) + duties = [e for e in ns.channels["Pulse1"].events if isinstance(e, DutyEvent)] + assert duties and duties[0].value == 1 # @2 maps to duty 1 (25%) + + +# --- Volume normalization --- + + +def test_pyxel_volume_normalization(): + source = "0: T120 L4 O4 V100\n C" + tokens = tokenize(source, "pyxel") + ns, errors = parse_pyxel(source, tokens) + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + assert notes + # V100 -> round(100/127*15) = 12 + assert notes[0].velocity == 12 + + +def test_pyxel_volume_zero(): + source = "0: T120 L4 O4 V0\n C" + tokens = tokenize(source, "pyxel") + ns, errors = parse_pyxel(source, tokens) + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + assert notes and notes[0].velocity == 0 + + +def test_pyxel_volume_max(): + source = "0: T120 L4 O4 V127\n C" + tokens = tokenize(source, "pyxel") + ns, errors = parse_pyxel(source, tokens) + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + assert notes and notes[0].velocity == 15 + + +# --- Gate time --- + + +def test_pyxel_gate_time(): + source = "0: T120 L4 O4 Q50\n C" + tokens = tokenize(source, "pyxel") + ns, errors = parse_pyxel(source, tokens) + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + assert notes and notes[0].gate_time == 0.5 + + +# --- Transpose --- + + +def test_pyxel_transpose(): + source = "0: T120 L4 O4 K2\n C" + tokens = tokenize(source, "pyxel") + ns, errors = parse_pyxel(source, tokens) + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + # C4=60, transpose+2 -> 62 + assert notes and notes[0].note_number == 62 + + +# --- Detune --- + + +def test_pyxel_detune(): + source = "0: T120 L4 O4 Y10\n C" + tokens = tokenize(source, "pyxel") + ns, errors = parse_pyxel(source, tokens) + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + assert notes and notes[0].detune_cents == 10.0 + + +# --- Repeat --- + + +def test_pyxel_repeat_expansion(): + source = "0: T120 L4 O4\n[ C D ]2" + tokens = tokenize(source, "pyxel") + ns, errors = parse_pyxel(source, tokens) + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + assert len(notes) == 4 + + +def test_pyxel_repeat_count3(): + source = "0: T120 L4 O4\n[ C D ]3" + tokens = tokenize(source, "pyxel") + ns, errors = parse_pyxel(source, tokens) + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + assert len(notes) == 6 + + +# --- Tie --- + + +def test_ppmck_tie_note(): + source = "A t120 l4 o4\n c4 & c4" + tokens = tokenize(source, "ppmck") + ns, errors = parse_ppmck(source, tokens) + # Tie should merge notes; check no error + assert not any(e.severity == "error" for e in errors) + + +def test_pyxel_tie_length(): + source = "0: T120 L4 O4\n C4 & 4" + tokens = tokenize(source, "pyxel") + ns, errors = parse_pyxel(source, tokens) + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + # & 4 extends last note by l4 ticks + assert notes and notes[0].duration > 192 # 192 + 48 = 240 + + +# --- Octave up/down --- + + +def test_ppmck_octave_up(): + source = "A t120 l4 o4\n c > d" + tokens = tokenize(source, "ppmck") + ns, errors = parse_ppmck(source, tokens) + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + assert notes[0].note_number == 60 # o4 c + assert notes[1].note_number == 74 # o5 d + + +def test_pyxel_octave_down(): + source = "0: T120 L4 O4\n C < D" + tokens = tokenize(source, "pyxel") + ns, errors = parse_pyxel(source, tokens) + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + assert notes[0].note_number == 60 # O4 C + assert notes[1].note_number == 50 # O3 D + + +# --- Length with dots --- + + +def test_ppmck_dotted_note(): + source = "A t120 l4 o4\n c." + tokens = tokenize(source, "ppmck") + ns, errors = parse_ppmck(source, tokens) + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + # l4 = 192 ticks, dotted = 192 + 96 = 288 + assert notes and notes[0].duration == 288 + + +# --- Triangle volume ignored in ppmck --- + + +def test_ppmck_triangle_volume_ignored(): + source = "T t120 l4 o3 v7\n c" + tokens = tokenize(source, "ppmck") + ns, errors = parse_ppmck(source, tokens) + notes = [e for e in ns.channels["Triangle"].events if isinstance(e, NoteEvent)] + assert notes and notes[0].velocity == 15 # default, v ignored + + +# --- @ENV/@VIB/@GLI --- + + +def test_pyxel_env_event(): + source = "0: T120 L4 O4\n@ENV1 100 10\n C" + tokens = tokenize(source, "pyxel") + ns, errors = parse_pyxel(source, tokens) + envs = [e for e in ns.channels["Pulse1"].events if isinstance(e, EnvelopeEvent)] + assert envs and envs[0].slot == 1 + + +def test_pyxel_vib_event(): + source = "0: T120 L4 O4\n@VIB1 10 20 5\n C" + tokens = tokenize(source, "pyxel") + ns, errors = parse_pyxel(source, tokens) + from mml_composemusic_mcp.ir import VibratoEvent + + vibs = [e for e in ns.channels["Pulse1"].events if isinstance(e, VibratoEvent)] + assert vibs and vibs[0].slot == 1 + + +def test_pyxel_gli_event(): + source = "0: T120 L4 O4\n@GLI1 100 50\n C" + tokens = tokenize(source, "pyxel") + ns, errors = parse_pyxel(source, tokens) + from mml_composemusic_mcp.ir import GlideEvent + + glis = [e for e in ns.channels["Pulse1"].events if isinstance(e, GlideEvent)] + assert glis and glis[0].slot == 1 + + +# --- Noise channel --- + + +def test_ppmck_noise_ignores_pitch(): + source = "N t120 l4 v10\n c r" + tokens = tokenize(source, "ppmck") + ns, errors = parse_ppmck(source, tokens) + notes = [e for e in ns.channels["Noise"].events if isinstance(e, NoteEvent)] + assert notes # NoteEvent is created but with midi=0 + assert notes[0].note_number == 0 # pitch ignored diff --git a/tests/test_synthesizer.py b/tests/test_synthesizer.py new file mode 100644 index 0000000..d9680b6 --- /dev/null +++ b/tests/test_synthesizer.py @@ -0,0 +1,192 @@ +"""Tests for the NES APU synthesizer engine.""" + +import wave + +import numpy as np +import pytest + +from mml_composemusic_mcp.ir import ( + NoteEvent, + NoteSequence, + VolumeEvent, +) +from mml_composemusic_mcp.synthesizer import ( + ChannelSynthesizer, + build_channel_summary, + midi_to_freq, + noise_period_to_rate, + synthesize, + tick_to_second, + write_wav, +) + +# --- Utility functions --- + + +def test_midi_to_freq_a4(): + assert midi_to_freq(69) == pytest.approx(440.0) + + +def test_midi_to_freq_c4(): + assert midi_to_freq(60) == pytest.approx(261.63, rel=1e-2) + + +def test_midi_to_freq_high(): + assert midi_to_freq(127) > 8000 + + +def test_noise_period_to_rate(): + rate = noise_period_to_rate(8, 0, 44100) + assert rate > 0 + + +def test_tick_to_second(): + assert tick_to_second(192, 120) == pytest.approx(0.5, rel=1e-6) + + +def test_tick_to_second_zero(): + assert tick_to_second(0, 120) == 0.0 + + +# --- Channel synthesizer --- + + +def test_pulse_render(): + synth = ChannelSynthesizer("pulse", 44100) + events = [NoteEvent(tick_position=0, duration=192, note_number=69, velocity=15)] + wave_out = synth.render(events, 192, 120, "ppmck") + assert len(wave_out) > 0 + assert np.any(wave_out != 0) + + +def test_triangle_render_ppmck(): + synth = ChannelSynthesizer("triangle", 44100) + events = [NoteEvent(tick_position=0, duration=192, note_number=69, velocity=7)] + wave_out = synth.render(events, 192, 120, "ppmck") + assert len(wave_out) > 0 + assert np.any(wave_out != 0) + # In ppmck mode, triangle amplitude should be full (velocity ignored) + peak = np.max(np.abs(wave_out)) + assert peak == pytest.approx(1.0, rel=1e-2) + + +def test_triangle_render_pyxel(): + synth = ChannelSynthesizer("triangle", 44100) + events = [ + VolumeEvent(tick_position=0, value=7), + NoteEvent(tick_position=0, duration=192, note_number=69, velocity=7), + ] + wave_out = synth.render(events, 192, 120, "pyxel") + assert len(wave_out) > 0 + assert np.any(wave_out != 0) + # In pyxel mode, velocity/15 scales amplitude + peak = np.max(np.abs(wave_out)) + assert peak == pytest.approx(7 / 15, rel=1e-1) + + +def test_noise_render_ppmck(): + synth = ChannelSynthesizer("noise", 44100) + events = [NoteEvent(tick_position=0, duration=192, note_number=0, velocity=10)] + wave_out = synth.render(events, 192, 120, "ppmck") + assert len(wave_out) > 0 + assert np.any(wave_out != 0) + + +def test_noise_render_pyxel(): + synth = ChannelSynthesizer("noise", 44100) + events = [NoteEvent(tick_position=0, duration=192, note_number=48, velocity=10)] + wave_out = synth.render(events, 192, 120, "pyxel") + assert len(wave_out) > 0 + assert np.any(wave_out != 0) + + +# --- Full synthesize --- + + +def test_synthesize_basic(): + ns = NoteSequence(bpm=120) + ns.channels["Pulse1"].events.append( + NoteEvent(tick_position=0, duration=192, note_number=60, velocity=15) + ) + ns.channels["Pulse1"].total_ticks = 192 + wave_data, duration, errors = synthesize(ns, "ppmck", 44100, True) + assert len(wave_data) > 0 + assert duration > 0 + assert errors == [] + + +def test_synthesize_empty(): + ns = NoteSequence(bpm=120) + wave_data, duration, errors = synthesize(ns, "ppmck", 44100, True) + assert len(wave_data) == 0 + assert duration == 0 + + +def test_synthesize_normalize(): + ns = NoteSequence(bpm=120) + ns.channels["Pulse1"].events.append( + NoteEvent(tick_position=0, duration=192, note_number=60, velocity=15) + ) + ns.channels["Pulse1"].total_ticks = 192 + on_data, _, _ = synthesize(ns, "ppmck", 44100, True) + off_data, _, _ = synthesize(ns, "ppmck", 44100, False) + # Normalized should have peak ~1.0 + assert np.max(np.abs(on_data)) == pytest.approx(1.0, rel=1e-2) + + +# --- WAV output --- + + +def test_write_wav_success(tmp_path): + path = tmp_path / "test.wav" + data = np.array([0.0, 0.5, -0.5, 0.0], dtype=np.float64) + errors = write_wav(path, data, 44100) + assert not errors + assert path.exists() + with wave.open(str(path), "rb") as wav: + assert wav.getnchannels() == 1 + assert wav.getsampwidth() == 2 + assert wav.getframerate() == 44100 + + +def test_write_wav_failure(tmp_path): + blocker = tmp_path / "blocker" + blocker.write_text("x") + bad_path = blocker / "output.wav" + data = np.array([0.0, 0.1], dtype=np.float64) + errors = write_wav(bad_path, data, 44100) + if errors: + from mml_composemusic_mcp.ir import ErrorCode + + assert errors[0].code == ErrorCode.SYSTEM_WAV_WRITE_FAILED + + +# --- Channel summary --- + + +def test_build_channel_summary(): + ns = NoteSequence(bpm=120) + ns.channels["Pulse1"].events.append( + NoteEvent(tick_position=0, duration=192, note_number=60, velocity=15) + ) + ns.channels["Pulse1"].events.append( + NoteEvent(tick_position=192, duration=192, note_number=72, velocity=15) + ) + ns.channels["Pulse1"].total_ticks = 384 + summary = build_channel_summary(ns) + pulse1 = [s for s in summary if s["channel"] == "Pulse1"][0] + assert pulse1["note_count"] == 2 + assert pulse1["duration_ticks"] == 384 + + +def test_build_channel_summary_from_dict(): + ns = NoteSequence(bpm=120) + ns.channels["Pulse1"].events.append( + NoteEvent(tick_position=0, duration=192, note_number=60, velocity=15) + ) + ns.channels["Pulse1"].total_ticks = 192 + ns_dict = ns.to_dict() + summary = build_channel_summary(ns_dict) + assert summary + pulse1 = [s for s in summary if s["channel"] == "Pulse1"][0] + assert pulse1["note_count"] == 1 diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..c6837b0 --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,83 @@ +"""Tests for MML template generation.""" + +import pytest + +from mml_composemusic_mcp.server import compose_mml +from mml_composemusic_mcp.templates import DESCRIPTIONS, TEMPLATES, get_template + + +def test_all_templates_exist(): + for mode in ("ppmck", "pyxel"): + for name in ("basic", "melody", "chord", "drum", "empty"): + assert mode in TEMPLATES + assert name in TEMPLATES[mode] + assert TEMPLATES[mode][name] + + +def test_all_descriptions_exist(): + for name in ("basic", "melody", "chord", "drum", "empty"): + assert name in DESCRIPTIONS + assert DESCRIPTIONS[name] + + +def test_get_template_basic(): + mml, desc = get_template("ppmck", "basic") + assert mml + assert desc == "基本的な4ch構成(メロディ+和音+ベース+リズム)" + + +def test_get_template_melody(): + mml, desc = get_template("pyxel", "melody") + assert mml + assert desc == "メロディ重視(Pulse1主旋律、他は伴奏最小限)" + + +def test_get_template_chord(): + mml, desc = get_template("ppmck", "chord") + assert mml + assert desc == "コード伴奏重視(Pulse2で和音、Triangleでベース)" + + +def test_get_template_drum(): + mml, desc = get_template("pyxel", "drum") + assert mml + assert desc == "リズム重視(Noise中心のビートパターン)" + + +def test_get_template_empty(): + mml, desc = get_template("ppmck", "empty") + assert mml + assert desc == "各チャンネルのヘッダーのみ(空のテンプレート)" + + +@pytest.mark.parametrize("mode", ["ppmck", "pyxel"]) +@pytest.mark.parametrize("template", ["basic", "melody", "chord", "drum", "empty"]) +def test_template_validates(mode, template): + mml, _ = get_template(mode, template) + result = compose_mml(action="validate", mml=mml, mode=mode) + assert result["valid"] is True, f"{mode}/{template} invalid: {result['errors']}" + + +def test_template_ppmck_has_headers(): + mml, _ = get_template("ppmck", "basic") + assert "#TITLE" in mml + + +def test_template_pyxel_has_track_headers(): + mml, _ = get_template("pyxel", "basic") + assert "0:" in mml + assert "1:" in mml + assert "2:" in mml + assert "3:" in mml + + +def test_template_empty_has_rests(): + mml, _ = get_template("ppmck", "empty") + # Even empty template should have rests (not truly empty) + assert "r" in mml.lower() + + +def test_template_invalid_name_falls_back(): + result = compose_mml(action="template", mode="ppmck", template="nonexistent") + assert result["mml"] + assert result["description"] diff --git a/tests/test_transports.py b/tests/test_transports.py new file mode 100644 index 0000000..395977b --- /dev/null +++ b/tests/test_transports.py @@ -0,0 +1,66 @@ +"""Tests for MCP server transport CLI argument parsing.""" + +from unittest.mock import patch + +import pytest + +from mml_composemusic_mcp.server import main + + +def test_transport_stdio_default(): + """Default transport should be stdio.""" + with patch("sys.argv", ["server", "--output-dir", "./data"]): + with patch("mml_composemusic_mcp.server.mcp.run") as mock_run: + main() + mock_run.assert_called_once() + call_kwargs = mock_run.call_args + assert call_kwargs[1]["transport"] == "stdio" + + +def test_transport_http(): + """HTTP transport should pass host and port.""" + with patch( + "sys.argv", + ["server", "--transport", "http", "--host", "0.0.0.0", "--port", "9000"], + ): + with patch("mml_composemusic_mcp.server.mcp.run") as mock_run: + main() + call_kwargs = mock_run.call_args + assert call_kwargs[1]["transport"] == "http" + assert call_kwargs[1]["host"] == "0.0.0.0" + assert call_kwargs[1]["port"] == 9000 + + +def test_transport_sse(): + """SSE transport should be accepted.""" + with patch("sys.argv", ["server", "--transport", "sse", "--port", "8080"]): + with patch("mml_composemusic_mcp.server.mcp.run") as mock_run: + main() + call_kwargs = mock_run.call_args + assert call_kwargs[1]["transport"] == "sse" + + +def test_transport_streamable_http(): + """Streamable HTTP transport should be accepted.""" + with patch("sys.argv", ["server", "--transport", "streamable-http"]): + with patch("mml_composemusic_mcp.server.mcp.run") as mock_run: + main() + call_kwargs = mock_run.call_args + assert call_kwargs[1]["transport"] == "streamable-http" + + +def test_transport_invalid_rejected(): + """Invalid transport should be rejected by argparse.""" + with patch("sys.argv", ["server", "--transport", "invalid"]): + with pytest.raises(SystemExit): + main() + + +def test_output_dir_override(tmp_path): + """--output-dir should set OUTPUT_DIR.""" + with patch("sys.argv", ["server", "--output-dir", str(tmp_path / "out")]): + with patch("mml_composemusic_mcp.server.mcp.run"): + import mml_composemusic_mcp.server as srv + + main() + assert srv.OUTPUT_DIR == tmp_path / "out" -- cgit v1.2.3