diff --git a/tests/test_score_io.py b/tests/test_score_io.py
new file mode 100644
index 0000000..b58af91
--- /dev/null
+++ b/tests/test_score_io.py
@@ -0,0 +1,264 @@
+"""Focused pytest coverage for score_io validation and parsing."""
+
+from __future__ import annotations
+
+import sys
+import zipfile
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+
+import registral_dispersion.score_io as score_io
+from registral_dispersion.score_io import (
+ ScoreValidationError,
+ _unsafe_zip_member_name,
+ parse_score,
+ validate_score_path,
+ validate_zip_archive,
+)
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+FIXTURE_XML = REPO_ROOT / "tests" / "fixtures" / "single_note.xml"
+
+
+# ---------------------------------------------------------------------------
+# _unsafe_zip_member_name
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("name", "expected"),
+ [
+ ("score.xml", False),
+ ("META-INF/container.xml", False),
+ ("../score.xml", True),
+ ("META-INF/../score.xml", True),
+ (r"..\score.xml", True),
+ ("/tmp/score.xml", True),
+ ],
+)
+def test_unsafe_zip_member_name(name: str, expected: bool) -> None:
+ assert _unsafe_zip_member_name(name) is expected
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="Windows drive-letter paths")
+def test_unsafe_zip_member_name_windows_absolute() -> None:
+ assert _unsafe_zip_member_name(r"C:\Users\evil.xml") is True
+
+
+# ---------------------------------------------------------------------------
+# validate_zip_archive
+# ---------------------------------------------------------------------------
+
+
+def test_validate_zip_archive_rejects_non_zip(tmp_path: Path) -> None:
+ bad = tmp_path / "broken.mxl"
+ bad.write_text("not a zip file")
+ with pytest.raises(ScoreValidationError, match="Invalid or corrupted ZIP"):
+ validate_zip_archive(str(bad))
+
+
+def test_validate_zip_archive_rejects_unsafe_member(tmp_path: Path) -> None:
+ zpath = tmp_path / "unsafe.mxl"
+ with zipfile.ZipFile(zpath, "w") as zf:
+ zf.writestr("../evil.xml", "")
+ with pytest.raises(ScoreValidationError, match="Unsafe path inside ZIP"):
+ validate_zip_archive(str(zpath))
+
+
+def test_validate_zip_archive_rejects_too_many_members(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(score_io, "MAX_ZIP_MEMBERS", 2)
+ zpath = tmp_path / "many.mxl"
+ with zipfile.ZipFile(zpath, "w") as zf:
+ for i in range(3):
+ zf.writestr(f"entry{i}.xml", f"")
+ with pytest.raises(ScoreValidationError, match="too many entries"):
+ validate_zip_archive(str(zpath))
+
+
+def test_validate_zip_archive_rejects_huge_single_member(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ zpath = tmp_path / "huge.mxl"
+ with zipfile.ZipFile(zpath, "w") as zf:
+ zf.writestr("score.xml", "")
+
+ real_zipfile = zipfile.ZipFile
+
+ class ZipFileWithHugeMember(real_zipfile):
+ def infolist(self):
+ infos = super().infolist()
+ infos[0].file_size = score_io.MAX_ZIP_SINGLE_UNCOMPRESSED_BYTES + 1
+ return infos
+
+ monkeypatch.setattr(score_io.zipfile, "ZipFile", ZipFileWithHugeMember)
+ with pytest.raises(ScoreValidationError, match="declares uncompressed size"):
+ validate_zip_archive(str(zpath))
+
+
+def test_validate_zip_archive_rejects_excessive_total_uncompressed(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(score_io, "MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES", 10)
+ zpath = tmp_path / "total.mxl"
+ with zipfile.ZipFile(zpath, "w") as zf:
+ zf.writestr("a.xml", "0123456789")
+ zf.writestr("b.xml", "0123456789")
+ with pytest.raises(ScoreValidationError, match="excessive total uncompressed size"):
+ validate_zip_archive(str(zpath))
+
+
+def test_validate_zip_archive_accepts_safe_zip(tmp_path: Path) -> None:
+ zpath = tmp_path / "ok.mxl"
+ with zipfile.ZipFile(zpath, "w") as zf:
+ zf.writestr("META-INF/container.xml", "")
+ zf.writestr("score.xml", "")
+ validate_zip_archive(str(zpath))
+
+
+# ---------------------------------------------------------------------------
+# validate_score_path
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("bad_path", [None, ""])
+def test_validate_score_path_rejects_missing_path(bad_path) -> None:
+ with pytest.raises(ScoreValidationError, match="No file path provided"):
+ validate_score_path(bad_path) # type: ignore[arg-type]
+
+
+def test_validate_score_path_rejects_missing_file(tmp_path: Path) -> None:
+ missing = tmp_path / "ghost.xml"
+ with pytest.raises(ScoreValidationError, match="Score file not found"):
+ validate_score_path(str(missing))
+
+
+def test_validate_score_path_rejects_unsupported_extension(tmp_path: Path) -> None:
+ bad = tmp_path / "notes.txt"
+ bad.write_text("hello")
+ with pytest.raises(ScoreValidationError, match="Unsupported extension"):
+ validate_score_path(str(bad))
+
+
+def test_validate_score_path_rejects_empty_file(tmp_path: Path) -> None:
+ empty = tmp_path / "empty.xml"
+ empty.write_bytes(b"")
+ with pytest.raises(ScoreValidationError, match="File is empty"):
+ validate_score_path(str(empty))
+
+
+def test_validate_score_path_rejects_file_too_large(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(score_io, "MAX_SCORE_FILE_BYTES", 5)
+ big = tmp_path / "big.xml"
+ big.write_text("0123456789")
+ with pytest.raises(ScoreValidationError, match="File too large"):
+ validate_score_path(str(big))
+
+
+def test_validate_score_path_mxl_calls_validate_zip_archive(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ zpath = tmp_path / "score.mxl"
+ with zipfile.ZipFile(zpath, "w") as zf:
+ zf.writestr("score.xml", "")
+ called: list[str] = []
+
+ def _spy(path: str) -> None:
+ called.append(path)
+ validate_zip_archive(path)
+
+ monkeypatch.setattr(score_io, "validate_zip_archive", _spy)
+ validate_score_path(str(zpath))
+ assert called == [str(zpath)]
+
+
+def test_validate_score_path_accepts_valid_xml(tmp_path: Path) -> None:
+ xml = tmp_path / "ok.xml"
+ xml.write_text("")
+ validate_score_path(str(xml))
+
+
+# ---------------------------------------------------------------------------
+# parse_score
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def patched_parse(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
+ mock_parse = MagicMock(return_value="parsed-score")
+ monkeypatch.setattr(score_io.m21.converter, "parse", mock_parse)
+ return mock_parse
+
+
+def test_parse_score_calls_validate_before_parse(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patched_parse: MagicMock
+) -> None:
+ xml = tmp_path / "score.xml"
+ xml.write_text("")
+ order: list[str] = []
+
+ def _validate(path: str) -> None:
+ order.append("validate")
+ validate_score_path(path)
+
+ monkeypatch.setattr(score_io, "validate_score_path", _validate)
+
+ def _parse(path: str, **kwargs):
+ order.append("parse")
+ return "parsed-score"
+
+ monkeypatch.setattr(score_io.m21.converter, "parse", _parse)
+
+ result = parse_score(str(xml))
+ assert result == "parsed-score"
+ assert order == ["validate", "parse"]
+
+
+@pytest.mark.parametrize("suffix", [".xml", ".musicxml", ".mxl"])
+def test_parse_score_musicxml_extensions_use_musicxml_format(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ patched_parse: MagicMock,
+ suffix: str,
+) -> None:
+ monkeypatch.setattr(score_io, "validate_score_path", lambda _path: None)
+ if suffix == ".mxl":
+ path = tmp_path / f"score{suffix}"
+ with zipfile.ZipFile(path, "w") as zf:
+ zf.writestr("score.xml", "")
+ else:
+ path = tmp_path / f"score{suffix}"
+ path.write_text("")
+
+ parse_score(str(path))
+ patched_parse.assert_called_once_with(str(path), format="musicxml")
+
+
+@pytest.mark.parametrize("suffix", [".mid", ".midi"])
+def test_parse_score_midi_extensions_without_musicxml_format(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ patched_parse: MagicMock,
+ suffix: str,
+) -> None:
+ monkeypatch.setattr(score_io, "validate_score_path", lambda _path: None)
+ path = tmp_path / f"score{suffix}"
+ path.write_bytes(b"MThd\x00\x00\x00\x06\x00\x00\x00\x01\x00\x00")
+
+ parse_score(str(path))
+ patched_parse.assert_called_once_with(str(path))
+
+
+def test_parse_score_integration_with_fixture(
+ monkeypatch: pytest.MonkeyPatch, patched_parse: MagicMock
+) -> None:
+ if not FIXTURE_XML.is_file():
+ pytest.skip("Fixture not found")
+ monkeypatch.setattr(score_io, "validate_score_path", lambda _path: None)
+ parse_score(str(FIXTURE_XML))
+ patched_parse.assert_called_once_with(str(FIXTURE_XML), format="musicxml")