summaryrefslogtreecommitdiff
path: root/tests/test_mml_conformance.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_mml_conformance.py')
-rw-r--r--tests/test_mml_conformance.py759
1 files changed, 757 insertions, 2 deletions
diff --git a/tests/test_mml_conformance.py b/tests/test_mml_conformance.py
index 442be81..82ec807 100644
--- a/tests/test_mml_conformance.py
+++ b/tests/test_mml_conformance.py
@@ -4,8 +4,38 @@ 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 DutyEvent, ErrorCode, NoteEvent
+from mml_composemusic_mcp.ast_nodes import (
+ BarStmt,
+ DetuneCmdStmt,
+ DetuneStmt,
+ DutyStmt,
+ ExtCmdStmt,
+ GateTimeStmt,
+ Header,
+ LengthStmt,
+ NoteStmt,
+ OctaveStmt,
+ QuantizeStmt,
+ RelativeVolumeStmt,
+ RepeatBreakStmt,
+ RepeatEndStmt,
+ RepeatStartStmt,
+ RestStmt,
+ SweepStmt,
+ TempoStmt,
+ TieCmdStmt,
+ TieStmt,
+ TransposeStmt,
+ VolumeEnvelopeDefStmt,
+ VolumeStmt,
+)
+from mml_composemusic_mcp.ir import (
+ DutyEvent,
+ ErrorCode,
+ ErrorPhase,
+ NoteEvent,
+ RepeatEvent,
+)
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
@@ -29,6 +59,18 @@ def error_codes(errors):
return {error.code for error in errors}
+def parser_errors(source: str, mode: str):
+ tokens = tokenize(source, mode)
+ parser = parse_ppmck if mode == "ppmck" else parse_pyxel
+ return parser(source, tokens)
+
+
+def first_statement(source: str, mode: str):
+ program, errors = parser_errors(source, mode)
+ assert not any(error.severity == "error" for error in errors)
+ return program.tracks[0].statements[0]
+
+
@pytest.mark.parametrize(
("mode", "source", "accidental", "length", "dots"),
[
@@ -268,3 +310,716 @@ def test_generated_single_token_corruption_is_not_silent(mode, bad, index):
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)
+
+
+@pytest.mark.parametrize(
+ ("mode", "source", "expected"),
+ [
+ pytest.param(
+ "ppmck",
+ "A c+4. r8 > < | ; comment",
+ [
+ TokenType.TRACK_HEADER,
+ TokenType.NOTE,
+ TokenType.SHARP,
+ TokenType.NUMBER,
+ TokenType.DOT,
+ TokenType.REST,
+ TokenType.NUMBER,
+ TokenType.OCTAVE_UP,
+ TokenType.OCTAVE_DOWN,
+ TokenType.BAR,
+ TokenType.COMMENT,
+ TokenType.EOF,
+ ],
+ id="BNF-COMMON-LEX-ppmck",
+ ),
+ pytest.param(
+ "pyxel",
+ "0: C#4. R8 > < |",
+ [
+ TokenType.TRACK_HEADER,
+ TokenType.NOTE,
+ TokenType.SHARP,
+ TokenType.NUMBER,
+ TokenType.DOT,
+ TokenType.REST,
+ TokenType.NUMBER,
+ TokenType.OCTAVE_UP,
+ TokenType.OCTAVE_DOWN,
+ TokenType.BAR,
+ TokenType.EOF,
+ ],
+ id="BNF-COMMON-LEX-pyxel",
+ ),
+ ],
+)
+def test_bnf_common_lexer_token_contract(mode, source, expected):
+ assert [token.type for token in tokenize(source, mode)] == expected
+
+
+@pytest.mark.parametrize(
+ ("source", "token_type", "value"),
+ [
+ pytest.param("A o7", TokenType.OCTAVE, "7", id="BNF-PPMCK-OCTAVE"),
+ pytest.param("A l192", TokenType.LENGTH, "192", id="BNF-PPMCK-LENGTH"),
+ pytest.param("A v15", TokenType.VOLUME, "15", id="BNF-PPMCK-VOLUME"),
+ pytest.param("A @3", TokenType.DUTY, "3", id="BNF-PPMCK-DUTY"),
+ pytest.param("A q8", TokenType.QUANTIZE, "8", id="BNF-PPMCK-QUANTIZE"),
+ pytest.param("A t120", TokenType.TEMPO, "120", id="BNF-PPMCK-TEMPO"),
+ pytest.param("A D-127", TokenType.DETUNE, "-127", id="BNF-PPMCK-DETUNE"),
+ pytest.param("A s7,-7", TokenType.SWEEP, "7,-7", id="BNF-PPMCK-SWEEP"),
+ pytest.param("A v+", TokenType.REL_VOL_UP, "", id="BNF-PPMCK-REL-VOL"),
+ pytest.param("A ^16", TokenType.TIE_CMD, "^", id="BNF-PPMCK-TIE"),
+ ],
+)
+def test_bnf_ppmck_command_lexer_contract(source, token_type, value):
+ token = next(
+ token for token in tokenize(source, "ppmck") if token.type == token_type
+ )
+ assert token.value == value
+
+
+@pytest.mark.parametrize(
+ ("source", "token_type", "value"),
+ [
+ pytest.param("0: O7", TokenType.OCTAVE, "7", id="BNF-PYXEL-OCTAVE"),
+ pytest.param("0: L192", TokenType.LENGTH, "192", id="BNF-PYXEL-LENGTH"),
+ pytest.param("0: V127", TokenType.VOLUME, "127", id="BNF-PYXEL-VOLUME"),
+ pytest.param("0: Q100", TokenType.GATE_TIME, "100", id="BNF-PYXEL-GATE"),
+ pytest.param("0: @3", TokenType.AT, "3", id="BNF-PYXEL-TONE"),
+ pytest.param("0: T120", TokenType.TEMPO, "120", id="BNF-PYXEL-TEMPO"),
+ pytest.param("0: K-127", TokenType.TRANSPOSE, "-127", id="BNF-PYXEL-TRANSPOSE"),
+ pytest.param("0: Y+127", TokenType.DETUNE, "+127", id="BNF-PYXEL-DETUNE"),
+ pytest.param("0: @ENV1 100 10", TokenType.EXT_CMD, "ENV", id="BNF-PYXEL-EXT"),
+ ],
+)
+def test_bnf_pyxel_command_lexer_contract(source, token_type, value):
+ token = next(
+ token for token in tokenize(source, "pyxel") if token.type == token_type
+ )
+ assert token.value == value
+
+
+@pytest.mark.parametrize(
+ ("source", "node_type", "attributes"),
+ [
+ pytest.param("A o7", OctaveStmt, {"value": 7}, id="BNF-PPMCK-AST-o"),
+ pytest.param("A >", OctaveStmt, {"direction": "up"}, id="BNF-PPMCK-AST-up"),
+ pytest.param("A l192", LengthStmt, {"value": 192}, id="BNF-PPMCK-AST-l"),
+ pytest.param("A v15", VolumeStmt, {"value": 15}, id="BNF-PPMCK-AST-v"),
+ pytest.param("A @3", DutyStmt, {"value": 3}, id="BNF-PPMCK-AST-duty"),
+ pytest.param("A q8", QuantizeStmt, {"value": 8}, id="BNF-PPMCK-AST-q"),
+ pytest.param("A t1", TempoStmt, {"value": 1}, id="BNF-PPMCK-AST-t"),
+ pytest.param("A D-127", DetuneCmdStmt, {"value": -127}, id="BNF-PPMCK-AST-D"),
+ pytest.param(
+ "A s7,-7", SweepStmt, {"speed": 7, "depth": -7}, id="BNF-PPMCK-AST-s"
+ ),
+ pytest.param(
+ "A v-15", RelativeVolumeStmt, {"delta": -15}, id="BNF-PPMCK-AST-rel-v"
+ ),
+ pytest.param("A ^16", TieCmdStmt, {"target": 16}, id="BNF-PPMCK-AST-tie"),
+ pytest.param("A &r8", TieStmt, {}, id="BNF-PPMCK-AST-slur"),
+ pytest.param("A |", BarStmt, {}, id="BNF-PPMCK-AST-bar"),
+ pytest.param(
+ "A @v1={15,12|8}",
+ VolumeEnvelopeDefStmt,
+ {"slot": 1, "points": [15, 12], "loop_points": [8]},
+ id="BNF-PPMCK-AST-envelope",
+ ),
+ ],
+)
+def test_bnf_ppmck_parser_ast_contract(source, node_type, attributes):
+ statement = first_statement(source, "ppmck")
+ assert isinstance(statement, node_type)
+ for name, value in attributes.items():
+ assert getattr(statement, name) == value
+ if source == "A &r8":
+ assert isinstance(statement.target, RestStmt)
+ assert statement.target.length == 8
+
+
+@pytest.mark.parametrize(
+ ("source", "node_type", "attributes"),
+ [
+ pytest.param("0: O7", OctaveStmt, {"value": 7}, id="BNF-PYXEL-AST-O"),
+ pytest.param(
+ "0: <", OctaveStmt, {"direction": "down"}, id="BNF-PYXEL-AST-down"
+ ),
+ pytest.param("0: L192", LengthStmt, {"value": 192}, id="BNF-PYXEL-AST-L"),
+ pytest.param("0: V127", VolumeStmt, {"value": 127}, id="BNF-PYXEL-AST-V"),
+ pytest.param("0: Q100", GateTimeStmt, {"value": 100}, id="BNF-PYXEL-AST-Q"),
+ pytest.param("0: @2", DutyStmt, {"value": 2}, id="BNF-PYXEL-AST-tone"),
+ pytest.param("0: T1", TempoStmt, {"value": 1}, id="BNF-PYXEL-AST-T"),
+ pytest.param("0: K-127", TransposeStmt, {"value": -127}, id="BNF-PYXEL-AST-K"),
+ pytest.param("0: Y127", DetuneStmt, {"value": 127}, id="BNF-PYXEL-AST-Y"),
+ pytest.param("0: &16", TieStmt, {"target": 16}, id="BNF-PYXEL-AST-tie"),
+ pytest.param(
+ "0: @VIB1 24 12 100",
+ ExtCmdStmt,
+ {"cmd": "VIB", "slot": 1, "params": [24, 12, 100]},
+ id="BNF-PYXEL-AST-extension",
+ ),
+ ],
+)
+def test_bnf_pyxel_parser_ast_contract(source, node_type, attributes):
+ statement = first_statement(source, "pyxel")
+ assert isinstance(statement, node_type)
+ for name, value in attributes.items():
+ assert getattr(statement, name) == value
+
+
+@pytest.mark.parametrize(
+ ("mode", "source", "count"),
+ [
+ pytest.param("ppmck", "A [c]2", 2, id="BNF-PPMCK-AST-repeat"),
+ pytest.param("pyxel", "0: [C]3", 3, id="BNF-PYXEL-AST-repeat"),
+ ],
+)
+def test_bnf_repeat_ast_requires_a_complete_structure(mode, source, count):
+ program, errors = parser_errors(source, mode)
+ assert not any(error.severity == "error" for error in errors)
+ statements = program.tracks[0].statements
+ assert isinstance(statements[0], RepeatStartStmt)
+ assert isinstance(statements[-1], RepeatEndStmt)
+ assert statements[-1].count == count
+
+
+@pytest.mark.parametrize(
+ ("mode", "source", "track_ids", "channels"),
+ [
+ pytest.param(
+ "ppmck",
+ "A c\nB d\nT e\nN r\nL",
+ ["A", "B", "T", "N", "L"],
+ ["Pulse1", "Pulse2", "Triangle", "Noise", "Loop"],
+ id="BNF-PPMCK-TRACKS",
+ ),
+ pytest.param(
+ "pyxel",
+ "0: C\n1: D\n2: E\n3: R",
+ ["0", "1", "2", "3"],
+ ["Pulse1", "Pulse2", "Triangle", "Noise"],
+ id="BNF-PYXEL-TRACKS",
+ ),
+ ],
+)
+def test_bnf_track_mapping(mode, source, track_ids, channels):
+ program, errors = parser_errors(source, mode)
+ assert not any(error.severity == "error" for error in errors)
+ assert [track.track_id for track in program.tracks] == track_ids
+ assert [track.channel for track in program.tracks] == channels
+
+
+def test_bnf_ppmck_header_ast_and_placement():
+ source = '#TITLE "Song"\n#COMPOSER "Composer"\nA c'
+ program, errors = parser_errors(source, "ppmck")
+ assert not errors
+ headers = program.tracks[0].headers
+ assert all(isinstance(header, Header) for header in headers)
+ assert [(header.key, header.value) for header in headers] == [
+ ("#TITLE", "Song"),
+ ("#COMPOSER", "Composer"),
+ ]
+
+
+@pytest.mark.parametrize(
+ ("mode", "source"),
+ [
+ pytest.param("ppmck", "A K12 c", id="BNF-MODE-ppmck-rejects-pyxel"),
+ pytest.param("pyxel", "0: v15 C", id="BNF-MODE-pyxel-rejects-ppmck"),
+ ],
+)
+def test_bnf_cross_mode_command_is_not_silent(mode, source):
+ _, _, _, errors = parse_pipeline(source, mode)
+ assert any(error.severity == "error" for error in errors)
+
+
+@pytest.mark.parametrize(
+ ("mode", "source"),
+ [
+ pytest.param("ppmck", "A c", id="BNF-DEFAULTS-ppmck"),
+ pytest.param("pyxel", "0: C", id="BNF-DEFAULTS-pyxel"),
+ ],
+)
+def test_bnf_default_note_state_reaches_ir(mode, source):
+ _, _, sequence, errors = parse_pipeline(source, mode)
+ 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.note_number == 60
+ assert note.duration == 192
+ assert note.gate_time == (1.0 if mode == "ppmck" else 0.8)
+
+
+@pytest.mark.parametrize(
+ ("mode", "source"),
+ [
+ pytest.param(
+ "ppmck",
+ "A o0 o7 l1 l192 v0 v15 @0 @3 q1 q8 t1 c",
+ id="BNF-RANGE-ppmck-valid",
+ ),
+ pytest.param("ppmck", "A s0,1 s7,-7 c", id="BNF-RANGE-ppmck-sweep-valid"),
+ pytest.param(
+ "pyxel",
+ "0: O0 O7 L1 L192 V0 V127 Q0 Q100 @1 @2 T1 C",
+ id="BNF-RANGE-pyxel-valid",
+ ),
+ pytest.param(
+ "pyxel", "0: K-127 K127 Y-127 Y127 C", id="BNF-RANGE-pyxel-signed-valid"
+ ),
+ ],
+)
+def test_bnf_semantic_boundary_values_are_accepted(mode, source):
+ _, _, _, errors = parse_pipeline(source, mode)
+ assert not any(error.severity == "error" for error in errors)
+
+
+@pytest.mark.parametrize(
+ ("mode", "source"),
+ [
+ pytest.param("ppmck", "A o8 c", id="BNF-RANGE-ppmck-o"),
+ pytest.param("ppmck", "A l0 c", id="BNF-RANGE-ppmck-l-low"),
+ pytest.param("ppmck", "A l193 c", id="BNF-RANGE-ppmck-l-high"),
+ pytest.param("ppmck", "A v16 c", id="BNF-RANGE-ppmck-v"),
+ pytest.param("ppmck", "A @4 c", id="BNF-RANGE-ppmck-duty"),
+ pytest.param("ppmck", "A q0 c", id="BNF-RANGE-ppmck-q-low"),
+ pytest.param("ppmck", "A q9 c", id="BNF-RANGE-ppmck-q-high"),
+ pytest.param("ppmck", "A t0 c", id="BNF-RANGE-ppmck-t"),
+ pytest.param("ppmck", "A s8,1 c", id="BNF-RANGE-ppmck-s-speed"),
+ pytest.param("ppmck", "A s0,0 c", id="BNF-RANGE-ppmck-s-depth"),
+ pytest.param("pyxel", "0: O8 C", id="BNF-RANGE-pyxel-O"),
+ pytest.param("pyxel", "0: L0 C", id="BNF-RANGE-pyxel-L-low"),
+ pytest.param("pyxel", "0: L193 C", id="BNF-RANGE-pyxel-L-high"),
+ pytest.param("pyxel", "0: V128 C", id="BNF-RANGE-pyxel-V"),
+ pytest.param("pyxel", "0: Q101 C", id="BNF-RANGE-pyxel-Q"),
+ pytest.param("pyxel", "0: @4 C", id="BNF-RANGE-pyxel-tone"),
+ pytest.param("pyxel", "0: T0 C", id="BNF-RANGE-pyxel-T"),
+ ],
+)
+def test_bnf_semantic_out_of_range_reports_phase(mode, source):
+ _, _, _, errors = parse_pipeline(source, mode)
+ matching = [
+ error for error in errors if error.code == ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE
+ ]
+ assert matching
+ assert all(error.phase == ErrorPhase.SEMANTIC for error in matching)
+
+
+@pytest.mark.parametrize(
+ ("mode", "source"),
+ [
+ pytest.param("ppmck", "T @2 c", id="BNF-CHANNEL-ppmck-duty"),
+ pytest.param("ppmck", "T s1,1 c", id="BNF-CHANNEL-ppmck-sweep"),
+ pytest.param("pyxel", "2: @1 C", id="BNF-CHANNEL-pyxel-tone"),
+ ],
+)
+def test_bnf_channel_mismatch_is_semantic(mode, source):
+ _, _, _, errors = parse_pipeline(source, mode)
+ mismatch = [
+ error for error in errors if error.code == ErrorCode.SEMANTIC_CHANNEL_MISMATCH
+ ]
+ assert mismatch
+ assert all(error.phase == ErrorPhase.SEMANTIC for error in mismatch)
+
+
+def test_bnf_pyxel_nested_repeat_reaches_ir():
+ _, _, sequence, errors = parse_pipeline("0: [C [D]2]3", "pyxel")
+ assert not any(error.severity == "error" for error in errors)
+ notes = [
+ event
+ for event in sequence.channels["Pulse1"].events
+ if isinstance(event, NoteEvent)
+ ]
+ assert len(notes) == 9
+
+
+@pytest.mark.parametrize(
+ ("source", "event_key"),
+ [
+ pytest.param("0: @ENV1 100 10 C", "envelopes", id="BNF-PYXEL-IR-ENV"),
+ pytest.param("0: @VIB1 24 12 100 C", "vibratos", id="BNF-PYXEL-IR-VIB"),
+ pytest.param("0: @GLI1 100 24 C", "glides", id="BNF-PYXEL-IR-GLI"),
+ ],
+)
+def test_bnf_pyxel_extension_definition_reaches_ir(source, event_key):
+ _, _, sequence, errors = parse_pipeline(source, "pyxel")
+ assert not any(error.severity == "error" for error in errors)
+ assert 1 in sequence.definitions[event_key]
+
+
+def test_parser_error_stops_pipeline_before_semantic_ir():
+ _, _, sequence, errors = parse_pipeline("0: C ? D", "pyxel")
+ assert sequence is None
+ assert ErrorCode.SYNTAX_INVALID_TOKEN in error_codes(errors)
+ assert all(error.phase == ErrorPhase.SYNTAX for error in errors)
+
+
+def test_semantic_error_is_distinguishable_from_parser_error():
+ _, program, sequence, errors = parse_pipeline("0: O8 C", "pyxel")
+ assert program.tracks
+ assert sequence is not None
+ assert any(error.phase == ErrorPhase.SEMANTIC for error in errors)
+
+
+@pytest.mark.parametrize(
+ ("mode", "source"),
+ [
+ pytest.param("ppmck", "A C", id="STRICT-CASE-ppmck-note"),
+ pytest.param("ppmck", "A O4 c", id="STRICT-CASE-ppmck-command"),
+ pytest.param("pyxel", "0: c", id="STRICT-CASE-pyxel-note"),
+ pytest.param("pyxel", "0: o4 C", id="STRICT-CASE-pyxel-command"),
+ pytest.param("pyxel", "0: @env1 1 1 C", id="STRICT-CASE-pyxel-ext"),
+ ],
+)
+def test_case_rules_are_enforced_by_the_lexer(mode, source):
+ tokens, _, sequence, errors = parse_pipeline(source, mode)
+ assert any(token.type == TokenType.INVALID for token in tokens)
+ assert sequence is None
+ assert ErrorCode.SYNTAX_INVALID_TOKEN in error_codes(errors)
+
+
+@pytest.mark.parametrize(
+ ("source", "raw"),
+ [
+ pytest.param("A D+", "D+", id="RAW-sign-only"),
+ pytest.param("A s1,-", "s1,-", id="RAW-sweep-sign-only"),
+ pytest.param("A s1 2", "s1", id="RAW-missing-comma"),
+ ],
+)
+def test_malformed_compound_tokens_preserve_source_raw_and_report(source, raw):
+ tokens, _, sequence, errors = parse_pipeline(source, "ppmck")
+ assert raw in [token.raw for token in tokens]
+ assert sequence is None
+ assert ErrorCode.SYNTAX_INVALID_NUMBER in error_codes(errors)
+
+
+def test_ppmck_top_level_order_and_global_definitions():
+ valid = '#TITLE "Song"\n@v1={15,8}\n@EP2={-12,+12}\nA @v1 EP2 c'
+ _, program, sequence, errors = parse_pipeline(valid, "ppmck")
+ assert not any(error.severity == "error" for error in errors)
+ assert len(program.global_statements) == 2
+ assert 1 in sequence.definitions["volume_envelopes"]
+ assert 2 in sequence.definitions["pitch_envelopes"]
+
+ for invalid in ('@v1={15}\n#TITLE "Late"\nA c', "c\nA c", "@v1\nA c"):
+ _, _, sequence, errors = parse_pipeline(invalid, "ppmck")
+ assert sequence is None
+ assert ErrorCode.SYNTAX_UNEXPECTED_TOKEN in error_codes(errors)
+
+
+def test_ppmck_repeat_break_ast_and_expansion():
+ source = "A l4 [c|d]3 e"
+ _, program, sequence, errors = parse_pipeline(source, "ppmck")
+ assert not any(error.severity == "error" for error in errors)
+ assert isinstance(program.tracks[0].statements[3], RepeatBreakStmt)
+ notes = [
+ event
+ for event in sequence.channels["Pulse1"].events
+ if isinstance(event, NoteEvent)
+ ]
+ assert [note.note_number for note in notes] == [60, 62, 60, 62, 60, 64]
+ assert [note.tick_position for note in notes] == [0, 192, 384, 576, 768, 960]
+
+
+@pytest.mark.parametrize(
+ ("source", "expected_notes"),
+ [
+ pytest.param("A [c d]1", [60, 62], id="REPEAT-normal-N1"),
+ pytest.param("A [c|d]1", [60], id="REPEAT-break-N1"),
+ pytest.param(
+ "A [c [d|e]2|f]2",
+ [60, 62, 64, 62, 65, 60, 62, 64, 62],
+ id="REPEAT-nested-breaks",
+ ),
+ ],
+)
+def test_ppmck_repeat_variants(source, expected_notes):
+ _, _, sequence, errors = parse_pipeline(source, "ppmck")
+ assert not any(error.severity == "error" for error in errors)
+ notes = [
+ event.note_number
+ for event in sequence.channels["Pulse1"].events
+ if isinstance(event, NoteEvent)
+ ]
+ assert notes == expected_notes
+
+
+@pytest.mark.parametrize(
+ "source",
+ [
+ pytest.param("A | c", id="REPEAT-outside-bar-valid"),
+ pytest.param("A [c|d|e]2", id="REPEAT-duplicate-break"),
+ pytest.param("A [c", id="REPEAT-unterminated"),
+ pytest.param("A c]2", id="REPEAT-extra-end"),
+ ],
+)
+def test_ppmck_repeat_structure_is_checked_in_parser(source):
+ program, errors = parser_errors(source, "ppmck")
+ if source == "A | c":
+ assert not any(error.severity == "error" for error in errors)
+ assert isinstance(program.tracks[0].statements[0], BarStmt)
+ else:
+ assert any(error.severity == "error" for error in errors)
+
+
+def test_infinite_repeat_has_two_cycle_preview_and_repeat_count_zero():
+ _, _, sequence, errors = parse_pipeline("A [c d]", "ppmck")
+ assert not any(error.severity == "error" for error in errors)
+ assert any(error.severity == "warning" for error in errors)
+ events = sequence.channels["Pulse1"].events
+ assert len([event for event in events if isinstance(event, NoteEvent)]) == 4
+ repeat = next(event for event in events if isinstance(event, RepeatEvent))
+ assert repeat.repeat_count == 0
+ assert (repeat.start_tick, repeat.end_tick) == (0, 768)
+
+
+@pytest.mark.parametrize(
+ ("count", "accepted"),
+ [
+ pytest.param(99_999, True, id="REPEAT-LIMIT-exact-100000-events"),
+ pytest.param(100_000, False, id="REPEAT-LIMIT-100001-events"),
+ ],
+)
+def test_repeat_expansion_event_limit_is_transactional(count, accepted):
+ _, _, sequence, errors = parse_pipeline(f"A [c]{count}", "ppmck")
+ range_errors = [
+ error for error in errors if error.code == ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE
+ ]
+ if accepted:
+ assert not range_errors
+ assert len(sequence.channels["Pulse1"].events) == 100_000
+ else:
+ assert range_errors
+ assert sequence.channels["Pulse1"].events == []
+
+
+def test_nested_repeat_limit_is_preflighted_without_partial_events():
+ _, _, sequence, errors = parse_pipeline("A [[c]500]201", "ppmck")
+ assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE in error_codes(errors)
+ assert sequence.channels["Pulse1"].events == []
+
+
+@pytest.mark.parametrize(
+ ("loop_body", "expected_start"),
+ [
+ pytest.param("", 0, id="LOOP-empty"),
+ pytest.param("l8 r", 96, id="LOOP-default-length"),
+ pytest.param("l8 r.", 144, id="LOOP-dotted-rest"),
+ pytest.param("l8 [r]3", 288, id="LOOP-finite-repeat"),
+ pytest.param("l8 [r|r]2", 288, id="LOOP-repeat-break"),
+ pytest.param("l8 [r]", 192, id="LOOP-infinite-preview"),
+ ],
+)
+def test_loop_track_calculates_global_loop_start(loop_body, expected_start):
+ _, _, sequence, errors = parse_pipeline(f"A l1 c\nL {loop_body}", "ppmck")
+ assert not any(error.severity == "error" for error in errors)
+ for channel in sequence.channels.values():
+ repeats = [event for event in channel.events if isinstance(event, RepeatEvent)]
+ assert repeats[-1].repeat_count == 0
+ assert repeats[-1].start_tick == expected_start
+ assert repeats[-1].end_tick == 768
+
+
+@pytest.mark.parametrize(
+ "source",
+ [
+ pytest.param("A c\nL c", id="LOOP-note-forbidden"),
+ pytest.param("A c\nL v1", id="LOOP-volume-forbidden"),
+ pytest.param("A c\nL r\nL", id="LOOP-duplicate"),
+ ],
+)
+def test_loop_track_rejects_forbidden_or_duplicate_content(source):
+ _, _, sequence, errors = parse_pipeline(source, "ppmck")
+ assert sequence is None
+ assert any(error.severity == "error" for error in errors)
+
+
+def test_loop_start_must_be_before_the_longest_audio_track_end():
+ _, _, sequence, errors = parse_pipeline("A l4 c\nL l4 r", "ppmck")
+ assert sequence is not None
+ assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE in error_codes(errors)
+ assert not any(
+ isinstance(event, RepeatEvent)
+ for channel in sequence.channels.values()
+ for event in channel.events
+ )
+
+
+@pytest.mark.parametrize(
+ ("source", "accepted"),
+ [
+ pytest.param("@v0={0,15}\nA c", True, id="ENV-volume-boundaries"),
+ pytest.param("@1={0,3}\nA c", True, id="ENV-duty-boundaries"),
+ pytest.param("@EP255={-127,+126}\nA c", True, id="ENV-pitch-signed"),
+ pytest.param("@EN255={-127,126}\nA c", True, id="ENV-note-signed"),
+ pytest.param("@v256={1}\nA c", False, id="ENV-slot-high"),
+ pytest.param("@v1={16}\nA c", False, id="ENV-volume-high"),
+ pytest.param("@1={4}\nA c", False, id="ENV-duty-high"),
+ pytest.param("@EP1={127}\nA c", False, id="ENV-pitch-high"),
+ pytest.param("@EN1={-128}\nA c", False, id="ENV-note-low"),
+ ],
+)
+def test_ppmck_definition_ranges(source, accepted):
+ _, _, _, errors = parse_pipeline(source, "ppmck")
+ range_errors = [
+ error for error in errors if error.code == ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE
+ ]
+ assert bool(range_errors) is not accepted
+
+
+@pytest.mark.parametrize(
+ "source",
+ [
+ pytest.param("@v1={}\nA c", id="DEF-empty"),
+ pytest.param("@v1={1\nA c", id="DEF-unterminated"),
+ pytest.param("@MP1={0,1,2}\nA c", id="DEF-lfo-missing-value"),
+ pytest.param("@MP1={0 1,2,0}\nA c", id="DEF-lfo-missing-comma"),
+ ],
+)
+def test_ppmck_broken_definitions_stop_before_semantics(source):
+ _, _, sequence, errors = parse_pipeline(source, "ppmck")
+ assert sequence is None
+ assert any(error.phase == ErrorPhase.SYNTAX for error in errors)
+
+
+@pytest.mark.parametrize(
+ ("cmd", "params", "accepted"),
+ [
+ pytest.param("ENV", "1 2", True, id="PYXEL-ENV-two"),
+ pytest.param("ENV", "1 2 3 4", True, id="PYXEL-ENV-four"),
+ pytest.param("ENV", "1", False, id="PYXEL-ENV-one"),
+ pytest.param("ENV", "1 2 3", False, id="PYXEL-ENV-odd"),
+ pytest.param("VIB", "1 2 3", True, id="PYXEL-VIB-three"),
+ pytest.param("VIB", "1 2", False, id="PYXEL-VIB-two"),
+ pytest.param("VIB", "1 2 3 4", False, id="PYXEL-VIB-four"),
+ pytest.param("GLI", "1 2", True, id="PYXEL-GLI-two"),
+ pytest.param("GLI", "1", False, id="PYXEL-GLI-one"),
+ pytest.param("GLI", "1 2 3", False, id="PYXEL-GLI-three"),
+ ],
+)
+def test_pyxel_extension_arity(cmd, params, accepted):
+ _, _, _, errors = parse_pipeline(f"0: @{cmd}1 {params} C", "pyxel")
+ range_errors = [
+ error for error in errors if error.code == ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE
+ ]
+ assert bool(range_errors) is not accepted
+
+
+def test_pyxel_extension_selection_and_definition_errors_use_source_position():
+ source = "0: @VIB1 1 2 3 C @VIB1 D"
+ _, _, sequence, errors = parse_pipeline(source, "pyxel")
+ assert not any(error.severity == "error" for error in errors)
+ assert 1 in sequence.definitions["vibratos"]
+
+ for invalid, code in (
+ ("0: @VIB1 C", ErrorCode.SEMANTIC_UNDEFINED_REFERENCE),
+ ("0: @VIB0 1 2 3 C", ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE),
+ ("0: @VIB1 1 2 3 @VIB1 4 5 6 C", ErrorCode.SEMANTIC_DUPLICATE_DEFINITION),
+ ):
+ _, _, _, errors = parse_pipeline(invalid, "pyxel")
+ matching = [error for error in errors if error.code == code]
+ assert matching and matching[0].line == 1 and matching[0].column > 0
+
+
+@pytest.mark.parametrize(
+ ("mode", "source"),
+ [
+ pytest.param("ppmck", "A c0", id="LENGTH-ppmck-note-zero"),
+ pytest.param("ppmck", "A c193", id="LENGTH-ppmck-note-high"),
+ pytest.param("ppmck", "A c ^0", id="LENGTH-ppmck-tie-zero"),
+ pytest.param("ppmck", "A c ^193", id="LENGTH-ppmck-tie-high"),
+ pytest.param("pyxel", "0: C0", id="LENGTH-pyxel-note-zero"),
+ pytest.param("pyxel", "0: C193", id="LENGTH-pyxel-note-high"),
+ pytest.param("pyxel", "0: C &0", id="LENGTH-pyxel-tie-zero"),
+ pytest.param("pyxel", "0: C &193", id="LENGTH-pyxel-tie-high"),
+ ],
+)
+def test_explicit_and_tie_lengths_are_checked_before_division(mode, source):
+ _, _, _, errors = parse_pipeline(source, mode)
+ assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE in error_codes(errors)
+
+
+def test_invalid_note_length_does_not_advance_state_or_add_partial_event():
+ _, _, sequence, errors = parse_pipeline("A l4 c c0 d", "ppmck")
+ assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE in error_codes(errors)
+ notes = [
+ event
+ for event in sequence.channels["Pulse1"].events
+ if isinstance(event, NoteEvent)
+ ]
+ assert len(notes) == 2
+ assert notes[1].tick_position == 192
+
+
+def test_compose_does_not_call_synthesizer_after_syntax_or_semantic_error(monkeypatch):
+ import mml_composemusic_mcp.server as server
+
+ called = False
+
+ def fail_if_called(*args, **kwargs): # type: ignore[no-untyped-def]
+ nonlocal called
+ called = True
+ raise AssertionError("synthesizer must not be called")
+
+ monkeypatch.setattr(server, "synthesize", fail_if_called)
+ for mode, source in (("ppmck", "A C"), ("pyxel", "0: O8 C")):
+ result = compose_mml(action="compose", mml=source, mode=mode)
+ assert result["success"] is False
+ assert called is False
+
+
+@pytest.mark.parametrize(
+ ("mode", "source"),
+ [
+ pytest.param("ppmck", "A [c]0", id="REPEAT-ZERO-ppmck"),
+ pytest.param("pyxel", "0: [C]0", id="REPEAT-ZERO-pyxel"),
+ ],
+)
+def test_repeat_count_zero_is_rejected_without_partial_expansion(mode, source):
+ _, _, sequence, errors = parse_pipeline(source, mode)
+ assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE in error_codes(errors)
+ assert sequence.channels["Pulse1"].events == []
+
+
+def test_ppmck_effect_reference_errors_keep_ast_source_position():
+ source = "A c @v7"
+ _, _, _, errors = parse_pipeline(source, "ppmck")
+ undefined = [
+ error
+ for error in errors
+ if error.code == ErrorCode.SEMANTIC_UNDEFINED_REFERENCE
+ ]
+ assert undefined and (undefined[0].line, undefined[0].column) == (1, 5)
+
+ duplicate_source = "@v1={1}\n@v1={2}\nA c"
+ _, _, _, errors = parse_pipeline(duplicate_source, "ppmck")
+ duplicate = [
+ error
+ for error in errors
+ if error.code == ErrorCode.SEMANTIC_DUPLICATE_DEFINITION
+ ]
+ assert duplicate and (duplicate[0].line, duplicate[0].column) == (2, 1)
+
+
+@pytest.mark.parametrize(
+ "source",
+ [
+ pytest.param("2: @0 C", id="PYXEL-TONE-triangle"),
+ pytest.param("3: @3 C", id="PYXEL-TONE-noise"),
+ ],
+)
+def test_pyxel_non_pulse_tones_match_their_track(source):
+ _, _, _, errors = parse_pipeline(source, "pyxel")
+ assert not any(error.severity == "error" for error in errors)
+
+
+def test_giant_numeric_value_is_diagnosed_without_parser_failure():
+ source = "A l" + ("9" * 1_000) + " c"
+ _, _, sequence, errors = parse_pipeline(source, "ppmck")
+ assert sequence is not None
+ assert ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE in error_codes(errors)