diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-07-12 17:58:19 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-07-12 17:59:16 +0900 |
| commit | bf36a773918959eeb6ffe7dae2fb8b0874438eb4 (patch) | |
| tree | da7ff0037ea923fea255d2ed69ed2fb51395d9b9 /tests | |
| parent | f1cd4d7d16f6e75a50bb0c587c0f759f7cf710cd (diff) | |
feat(ppmck): mckc準拠の構文拡張とIR保持コマンド追加
- @をデューティ比、qをクオンタイズに修正(mckc原本準拠)
- ^タイ、Dディチューン、sスイープ、v+/v-相対音量を追加
- @v/@@/@MP/@EP/@EN エンベロープ・LFO系を構文解析してIR保持
- 上記IR保持コマンドは合成未対応を SEMANTIC_UNSUPPORTED_FEATURE で警告
- Design.md / MML_BNF.md を更新
- 全200テスト + ruff チェック通過
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_error_codes.py | 2 | ||||
| -rw-r--r-- | tests/test_lexer.py | 74 | ||||
| -rw-r--r-- | tests/test_mml_conformance.py | 53 | ||||
| -rw-r--r-- | tests/test_parser.py | 10 | ||||
| -rw-r--r-- | tests/test_parser_detailed.py | 74 |
5 files changed, 200 insertions, 13 deletions
diff --git a/tests/test_error_codes.py b/tests/test_error_codes.py index f9bd5bb..538056f 100644 --- a/tests/test_error_codes.py +++ b/tests/test_error_codes.py @@ -192,7 +192,7 @@ def test_semantic_note_out_of_range_pyxel(compose_mml): # --- SEMANTIC_CHANNEL_MISMATCH --- def test_semantic_channel_mismatch_ppmck_triangle_duty(compose_mml): - result = compose_mml(action="validate", mml="T t120 l4 q2\n c", mode="ppmck") + result = compose_mml(action="validate", mml="T t120 l4 @2\n c", mode="ppmck") assert _find_code(result, ErrorCode.SEMANTIC_CHANNEL_MISMATCH.value, "warnings") diff --git a/tests/test_lexer.py b/tests/test_lexer.py index 64fa17c..1d7f8e7 100644 --- a/tests/test_lexer.py +++ b/tests/test_lexer.py @@ -4,12 +4,13 @@ from mml_composemusic_mcp.lexer import TokenType, tokenize def test_ppmck_tokens(): - source = "A t150 l8 o4 v15 q2\nc d e f" + source = "A t150 l8 o4 v15 @2\nc d e f" tokens = tokenize(source, "ppmck") types = [t.type for t in tokens] assert TokenType.TRACK_HEADER in types assert TokenType.TEMPO in types assert TokenType.NOTE in types + assert TokenType.DUTY in types def test_pyxel_tokens(): @@ -35,3 +36,74 @@ def test_ppmck_invalid_token(): def test_pyxel_invalid_token(): tokens = tokenize("0: T120 L4 O4 X C", "pyxel") assert any(t.type == TokenType.INVALID for t in tokens) + + +def test_ppmck_at_duty(): + tokens = tokenize("A @2 c", "ppmck") + assert any(t.type == TokenType.DUTY for t in tokens) + + +def test_ppmck_quantize(): + tokens = tokenize("A q4 c", "ppmck") + assert any(t.type == TokenType.QUANTIZE for t in tokens) + + +def test_ppmck_tie_cmd(): + tokens = tokenize("A c4 ^4", "ppmck") + assert any(t.type == TokenType.TIE_CMD for t in tokens) + + +def test_ppmck_detune(): + tokens = tokenize("A D10 c", "ppmck") + assert any(t.type == TokenType.DETUNE for t in tokens) + + +def test_ppmck_sweep(): + tokens = tokenize("A s1,2 c", "ppmck") + assert any(t.type == TokenType.SWEEP for t in tokens) + + +def test_ppmck_rel_vol(): + tokens = tokenize("A v+5 v-3 c", "ppmck") + assert any(t.type == TokenType.REL_VOL_UP for t in tokens) + assert any(t.type == TokenType.REL_VOL_DOWN for t in tokens) + + +def test_ppmck_vol_env(): + tokens = tokenize("A @v0 c", "ppmck") + assert any(t.type == TokenType.VOL_ENV for t in tokens) + + +def test_ppmck_vol_env_def(): + tokens = tokenize("@v0 = { 15, 10, | 5 }", "ppmck") + assert any(t.type == TokenType.VOL_ENV for t in tokens) + assert any(t.type == TokenType.BRACE_OPEN for t in tokens) + assert any(t.type == TokenType.BRACE_CLOSE for t in tokens) + + +def test_ppmck_lfo_def(): + tokens = tokenize("@MP0 = { 0, 10, 5, 0 }", "ppmck") + assert any(t.type == TokenType.LFO_DEF for t in tokens) + + +def test_ppmck_lfo_use_off(): + tokens = tokenize("A MP1 MPOF c", "ppmck") + assert any(t.type == TokenType.LFO_USE for t in tokens) + assert any(t.type == TokenType.LFO_OFF for t in tokens) + + +def test_ppmck_pitch_env(): + tokens = tokenize("A EP1 EPOF c", "ppmck") + assert any(t.type == TokenType.PITCH_ENV_USE for t in tokens) + assert any(t.type == TokenType.PITCH_ENV_OFF for t in tokens) + + +def test_ppmck_note_env(): + tokens = tokenize("A EN1 ENOF c", "ppmck") + assert any(t.type == TokenType.NOTE_ENV_USE for t in tokens) + assert any(t.type == TokenType.NOTE_ENV_OFF for t in tokens) + + +def test_ppmck_duty_env_use(): + tokens = tokenize("A @@0 c", "ppmck") + assert any(t.type == TokenType.DUTY_ENV_USE for t in tokens) diff --git a/tests/test_mml_conformance.py b/tests/test_mml_conformance.py index 0e38945..f611f6e 100644 --- a/tests/test_mml_conformance.py +++ b/tests/test_mml_conformance.py @@ -5,7 +5,7 @@ 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.ir import DutyEvent, 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 @@ -58,7 +58,7 @@ def test_note_follows_bnf_accidental_before_length( @pytest.mark.parametrize( ("mode", "source"), [ - ("ppmck", "A o l v q t c"), + ("ppmck", "A o l v @ t c"), ("pyxel", "0: O L V Q @ T K Y C"), ("pyxel", "0: K+ Y- C"), ], @@ -149,6 +149,55 @@ def test_validate_full_pyxel_path_preserves_warning_and_summary(): ) +@pytest.mark.parametrize( + ("source", "expected_warning_code"), + [ + ("A D10 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), + ("A s1,2 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), + ("A v+5 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), + ("A @v0 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), + ("A @@0 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), + ("A MP1 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), + ("A MPOF c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), + ("A EP1 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), + ("A EPOF c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), + ("A EN1 c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), + ("A ENOF c", ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE), + ], +) +def test_ppmck_new_commands_produce_warning(source, expected_warning_code): + _, _, _, errors = parse_pipeline(source, "ppmck") + assert not any(e.severity == "error" for e in errors) + assert expected_warning_code in error_codes(errors) + + +def test_ppmck_at_duty_in_pulse_channel(): + _, _, ns, errors = parse_pipeline("A @2 c", "ppmck") + assert not any(e.severity == "error" for e in errors) + duties = [e for e in ns.channels["Pulse1"].events if isinstance(e, DutyEvent)] + assert duties and duties[0].value == 2 + + +def test_ppmck_at_duty_in_triangle_channel_warns(): + _, _, _, errors = parse_pipeline("T @2 c", "ppmck") + assert ErrorCode.SEMANTIC_CHANNEL_MISMATCH in error_codes(errors) + + +def test_ppmck_quantize_sets_gate_time(): + _, _, ns, errors = parse_pipeline("A q4 c", "ppmck") + assert not any(e.severity == "error" for e in errors) + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + assert notes and notes[0].gate_time == 0.5 + + +def test_ppmck_tie_cmd_extends_duration(): + _, _, ns, errors = parse_pipeline("A c4 ^4", "ppmck") + assert not any(e.severity == "error" for e in errors) + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + assert len(notes) == 1 + assert notes[0].duration == 384 + + note_names = st.sampled_from(tuple("cdefgab")) accidentals = st.sampled_from(("", "+", "#", "-")) lengths = st.one_of(st.just(""), st.integers(1, 192).map(str)) diff --git a/tests/test_parser.py b/tests/test_parser.py index a0f11c7..291eb9b 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -6,7 +6,7 @@ from mml_composemusic_mcp.parser_ppmck import parse_ppmck from mml_composemusic_mcp.parser_pyxel import parse_pyxel PPMCK_EXAMPLE = """#TITLE "Test" -A t150 l8 o4 v15 q2 +A t150 l8 o4 v15 @2 c d e f B o3 c e g @@ -27,9 +27,7 @@ def test_ppmck_parse(): assert len(program.tracks) == 2 assert program.tracks[0].channel == "Pulse1" assert program.tracks[1].channel == "Pulse2" - notes = [ - s for s in program.tracks[0].statements if isinstance(s, NoteStmt) - ] + notes = [s for s in program.tracks[0].statements if isinstance(s, NoteStmt)] assert notes[0].note_name == "c" @@ -40,9 +38,7 @@ def test_pyxel_parse(): assert not any(e.severity == "error" for e in errors) assert len(program.tracks) == 2 assert program.tracks[0].channel == "Pulse1" - notes = [ - s for s in program.tracks[0].statements if isinstance(s, NoteStmt) - ] + notes = [s for s in program.tracks[0].statements if isinstance(s, NoteStmt)] assert notes[0].note_name == "c" diff --git a/tests/test_parser_detailed.py b/tests/test_parser_detailed.py index 7033d54..f1f4b6a 100644 --- a/tests/test_parser_detailed.py +++ b/tests/test_parser_detailed.py @@ -3,6 +3,7 @@ from mml_composemusic_mcp.ir import ( DutyEvent, EnvelopeEvent, + ErrorCode, NoteEvent, ) from mml_composemusic_mcp.lexer import tokenize @@ -54,13 +55,82 @@ def test_pyxel_channel_assignment(): # --- Duty --- -def test_ppmck_duty_q(): - source = "A t120 l4 o4 q3\n c" +def test_ppmck_duty_at(): + source = "A t120 l4 o4 @3\n c" ns, errors = _analyze_ppmck(source) duties = [e for e in ns.channels["Pulse1"].events if isinstance(e, DutyEvent)] assert duties and duties[0].value == 3 +def test_ppmck_quantize(): + source = "A t120 l4 o4 q4\n c" + ns, errors = _analyze_ppmck(source) + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + assert notes and notes[0].gate_time == 0.5 + + +def test_ppmck_tie_cmd(): + source = "A t120 l4 o4\n c4 ^4" + ns, errors = _analyze_ppmck(source) + notes = [e for e in ns.channels["Pulse1"].events if isinstance(e, NoteEvent)] + assert len(notes) == 1 + assert notes[0].duration == 384 + + +def test_ppmck_detune_warning(): + source = "A t120 l4 o4 D10\n c" + ns, errors = _analyze_ppmck(source) + assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors) + + +def test_ppmck_sweep_warning(): + source = "A t120 l4 o4 s1,2\n c" + ns, errors = _analyze_ppmck(source) + assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors) + + +def test_ppmck_rel_vol_warning(): + source = "A t120 l4 o4 v+5\n c" + ns, errors = _analyze_ppmck(source) + assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors) + + +def test_ppmck_vol_env_use_warning(): + source = "A t120 l4 o4 @v0\n c" + ns, errors = _analyze_ppmck(source) + assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors) + + +def test_ppmck_vol_env_def_warning(): + source = "A t120 l4 o4\n@v0 = { 15, 10, | 5 }\n c" + ns, errors = _analyze_ppmck(source) + assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors) + + +def test_ppmck_lfo_def_warning(): + source = "A t120 l4 o4\n@MP0 = { 0, 10, 5, 0 }\n c" + ns, errors = _analyze_ppmck(source) + assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors) + + +def test_ppmck_duty_env_use_warning(): + source = "A t120 l4 o4 @@0\n c" + ns, errors = _analyze_ppmck(source) + assert any(e.code == ErrorCode.SEMANTIC_UNSUPPORTED_FEATURE for e in errors) + + +def test_ppmck_duty_triangle_error(): + source = "T t120 l4 o3 @2\n c" + ns, errors = _analyze_ppmck(source) + assert any(e.code == ErrorCode.SEMANTIC_CHANNEL_MISMATCH for e in errors) + + +def test_ppmck_quantize_range_error(): + source = "A t120 l4 o4 q9\n c" + ns, errors = _analyze_ppmck(source) + assert any(e.code == ErrorCode.SEMANTIC_VALUE_OUT_OF_RANGE for e in errors) + + def test_pyxel_at_duty(): source = "0: T120 L4 O4 @1\n C" ns, errors = _analyze_pyxel(source) |
