summaryrefslogtreecommitdiff
path: root/tests/test_mml_conformance.py
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-07-12 16:20:37 +0900
committertanaka takashi <isazaka@gmail.com>2026-07-12 16:20:37 +0900
commit8a1fcf81dbab9e2eff3c4d558c6910bc8208e49e (patch)
tree70e6f5f867c8308f72fc106eb4ce0634c146d651 /tests/test_mml_conformance.py
parent22f96694a96ed9fba169e77bc27d19d00edca083 (diff)
MML構文解析処理一部修正。ドキュメント最新化
Diffstat (limited to 'tests/test_mml_conformance.py')
-rw-r--r--tests/test_mml_conformance.py210
1 files changed, 210 insertions, 0 deletions
diff --git a/tests/test_mml_conformance.py b/tests/test_mml_conformance.py
new file mode 100644
index 0000000..0e38945
--- /dev/null
+++ b/tests/test_mml_conformance.py
@@ -0,0 +1,210 @@
+"""BNF conformance tests spanning lexer, parser, semantics, and MCP validate."""
+
+import pytest
+from hypothesis import given, seed, settings
+from hypothesis import strategies as st
+
+from mml_composemusic_mcp.ast_nodes import NoteStmt, TransposeStmt
+from mml_composemusic_mcp.ir import ErrorCode, NoteEvent
+from mml_composemusic_mcp.lexer import TokenType, tokenize
+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
+
+
+def parse_pipeline(source: str, mode: str):
+ tokens = tokenize(source, mode)
+ parser = parse_ppmck if mode == "ppmck" else parse_pyxel
+ analyzer = analyze_ppmck if mode == "ppmck" else analyze_pyxel
+ program, errors = parser(source, tokens)
+ if any(error.severity == "error" for error in errors):
+ return tokens, program, None, errors
+ sequence, semantic_errors = analyzer(source, program)
+ return tokens, program, sequence, errors + semantic_errors
+
+
+def error_codes(errors):
+ return {error.code for error in errors}
+
+
+@pytest.mark.parametrize(
+ ("mode", "source", "accidental", "length", "dots"),
+ [
+ ("ppmck", "A c+4.", 1, 4, 1),
+ ("ppmck", "A d#8", 1, 8, 0),
+ ("ppmck", "A e-16..", -1, 16, 2),
+ ("pyxel", "0: C+4.", 1, 4, 1),
+ ("pyxel", "0: D#8", 1, 8, 0),
+ ("pyxel", "0: E-16..", -1, 16, 2),
+ ],
+)
+def test_note_follows_bnf_accidental_before_length(
+ mode, source, accidental, length, dots
+):
+ _, program, _, errors = parse_pipeline(source, mode)
+ assert not any(error.severity == "error" for error in errors)
+ note = next(
+ stmt for stmt in program.tracks[0].statements if isinstance(stmt, NoteStmt)
+ )
+ assert (note.accidental, note.length, note.dots) == (
+ accidental,
+ length,
+ dots,
+ )
+
+
+@pytest.mark.parametrize(
+ ("mode", "source"),
+ [
+ ("ppmck", "A o l v q t c"),
+ ("pyxel", "0: O L V Q @ T K Y C"),
+ ("pyxel", "0: K+ Y- C"),
+ ],
+)
+def test_commands_requiring_numbers_reject_omission(mode, source):
+ _, _, _, errors = parse_pipeline(source, mode)
+ assert ErrorCode.SYNTAX_INVALID_NUMBER in error_codes(errors)
+
+
+@pytest.mark.parametrize(
+ ("mode", "source"), [("ppmck", "A c++4"), ("pyxel", "0: C##4")]
+)
+def test_multiple_accidentals_are_rejected(mode, source):
+ _, _, _, errors = parse_pipeline(source, mode)
+ assert ErrorCode.SYNTAX_UNEXPECTED_TOKEN in error_codes(errors)
+
+
+@pytest.mark.parametrize(("command", "value"), [("K-12", -12), ("K+7", 7)])
+def test_pyxel_signed_transpose_is_preserved(command, value):
+ _, program, sequence, errors = parse_pipeline(f"0: O4 {command} C", "pyxel")
+ assert not any(error.severity == "error" for error in errors)
+ transpose = next(
+ stmt for stmt in program.tracks[0].statements if isinstance(stmt, TransposeStmt)
+ )
+ note = next(
+ event
+ for event in sequence.channels["Pulse1"].events
+ if isinstance(event, NoteEvent)
+ )
+ assert transpose.value == value
+ assert note.note_number == 60 + value
+
+
+def test_pyxel_signed_detune_reaches_ir():
+ _, _, sequence, errors = parse_pipeline("0: Y-25 C", "pyxel")
+ assert not any(error.severity == "error" for error in errors)
+ note = next(
+ event
+ for event in sequence.channels["Pulse1"].events
+ if isinstance(event, NoteEvent)
+ )
+ assert note.detune_cents == -25.0
+
+
+@pytest.mark.parametrize("source", ["L c\nL d", "A c\nA d"])
+def test_ppmck_all_track_ids_reject_duplicates(source):
+ _, _, _, errors = parse_pipeline(source, "ppmck")
+ assert ErrorCode.SYNTAX_DUPLICATE_TRACK in error_codes(errors)
+
+
+@pytest.mark.parametrize(
+ "source",
+ ['#UNKNOWN "x"\nA c', "#TITLE unquoted\nA c", 'A c\n#TITLE "late"'],
+)
+def test_ppmck_header_key_and_placement_are_enforced(source):
+ _, _, _, errors = parse_pipeline(source, "ppmck")
+ assert ErrorCode.SYNTAX_UNEXPECTED_TOKEN in error_codes(errors)
+
+
+def test_lexer_reports_precise_token_position_and_eof():
+ tokens = tokenize("0: C\n ?", "pyxel")
+ invalid = next(token for token in tokens if token.type == TokenType.INVALID)
+ assert (invalid.line, invalid.column, invalid.raw) == (2, 3, "?")
+ assert tokens[-1].type == TokenType.EOF
+
+
+@pytest.mark.parametrize("mode", ["ppmck", "pyxel"])
+def test_empty_and_unicode_inputs_do_not_crash_pipeline(mode):
+ sequence, errors = _parse_mml("", mode)
+ assert sequence is not None
+ assert isinstance(errors, list)
+ sequence, errors = _parse_mml("♬", mode)
+ assert sequence is None
+ assert ErrorCode.SYNTAX_INVALID_TOKEN in error_codes(errors)
+
+
+def test_validate_full_pyxel_path_preserves_warning_and_summary():
+ result = compose_mml(
+ action="validate",
+ mode="pyxel",
+ mml="0: T120 L4 O4 V100 Q80 @1 K-1 Y+5 @ENV1 100 10 C+4.",
+ )
+ assert result["valid"] is True
+ assert result["channel_summary"]
+ assert any(
+ warning["code"] == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE.value
+ for warning in result["warnings"]
+ )
+
+
+note_names = st.sampled_from(tuple("cdefgab"))
+accidentals = st.sampled_from(("", "+", "#", "-"))
+lengths = st.one_of(st.just(""), st.integers(1, 192).map(str))
+dots = st.integers(0, 3).map(lambda count: "." * count)
+spaces = st.sampled_from((" ", " ", "\t", "\n"))
+
+
+@seed(20260712)
+@settings(max_examples=100, deadline=None)
+@given(
+ mode=st.sampled_from(("ppmck", "pyxel")),
+ notes=st.lists(
+ st.tuples(note_names, accidentals, lengths, dots), min_size=1, max_size=20
+ ),
+ separator=spaces,
+)
+def test_generated_valid_notes_round_trip_to_ast_and_ir(mode, notes, separator):
+ rendered = [
+ name + accidental + length + dot for name, accidental, length, dot in notes
+ ]
+ if mode == "pyxel":
+ rendered = [note.upper() for note in rendered]
+ source = "0: " + separator.join(rendered)
+ else:
+ source = "A " + separator.join(rendered)
+ _, program, sequence, errors = parse_pipeline(source, mode)
+ assert not any(error.severity == "error" for error in errors)
+ ast_notes = [
+ stmt for stmt in program.tracks[0].statements if isinstance(stmt, NoteStmt)
+ ]
+ ir_notes = [
+ event
+ for event in sequence.channels["Pulse1"].events
+ if isinstance(event, NoteEvent)
+ ]
+ assert len(ast_notes) == len(notes) == len(ir_notes)
+
+
+@seed(20260712)
+@settings(max_examples=50, deadline=None)
+@given(
+ mode=st.sampled_from(("ppmck", "pyxel")),
+ bad=st.sampled_from(("?", "{", "}", ",", "♬")),
+ index=st.integers(min_value=0, max_value=4),
+)
+def test_generated_single_token_corruption_is_not_silent(mode, bad, index):
+ note = "cdefg"[index]
+ source = f"A {note}{bad}" if mode == "ppmck" else f"0: {note.upper()}{bad}"
+ _, _, _, errors = parse_pipeline(source, mode)
+ assert any(error.severity == "error" for error in errors)
+
+
+@pytest.mark.parametrize(
+ ("mode", "source"),
+ [("ppmck", "A l" + "9" * 1000 + " c"), ("pyxel", "0: L" + "9" * 1000 + " C")],
+)
+def test_very_large_numbers_are_reported_without_crashing(mode, source):
+ _, _, _, errors = parse_pipeline(source, mode)
+ assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE in error_codes(errors)