import zipfile
from unittest.mock import patch
import httpx
import pytest
from typer.testing import CliRunner
from python.sheet_music_ocr.audiveris import AudiverisError, find_audiveris, run_audiveris
from python.sheet_music_ocr.main import SUPPORTED_EXTENSIONS, app, extract_mxml_from_mxl
from python.sheet_music_ocr.review import LLMProvider, ReviewError, review_mxml
runner = CliRunner()
def make_mxl(path, xml_content=b""):
"""Create a minimal .mxl (ZIP) file with a MusicXML inside."""
with zipfile.ZipFile(path, "w") as zf:
zf.writestr("score.xml", xml_content)
class TestExtractMxmlFromMxl:
def test_extracts_xml(self, tmp_path):
mxl = tmp_path / "test.mxl"
output = tmp_path / "output.mxml"
content = b"hello"
make_mxl(mxl, content)
result = extract_mxml_from_mxl(mxl, output)
assert result == output
assert output.read_bytes() == content
def test_skips_meta_inf(self, tmp_path):
mxl = tmp_path / "test.mxl"
output = tmp_path / "output.mxml"
with zipfile.ZipFile(mxl, "w") as zf:
zf.writestr("META-INF/container.xml", "")
zf.writestr("score.xml", b"")
extract_mxml_from_mxl(mxl, output)
assert output.read_bytes() == b""
def test_raises_when_no_xml(self, tmp_path):
mxl = tmp_path / "test.mxl"
output = tmp_path / "output.mxml"
with zipfile.ZipFile(mxl, "w") as zf:
zf.writestr("readme.txt", "no xml here")
with pytest.raises(FileNotFoundError, match="No MusicXML"):
extract_mxml_from_mxl(mxl, output)
class TestFindAudiveris:
def test_raises_when_not_found(self):
with (
patch("python.sheet_music_ocr.audiveris.shutil.which", return_value=None),
pytest.raises(AudiverisError, match="not found"),
):
find_audiveris()
def test_returns_path_when_found(self):
with patch("python.sheet_music_ocr.audiveris.shutil.which", return_value="/usr/bin/audiveris"):
assert find_audiveris() == "/usr/bin/audiveris"
class TestRunAudiveris:
def test_raises_on_nonzero_exit(self, tmp_path):
with (
patch("python.sheet_music_ocr.audiveris.find_audiveris", return_value="audiveris"),
patch("python.sheet_music_ocr.audiveris.subprocess.run") as mock_run,
):
mock_run.return_value.returncode = 1
mock_run.return_value.stderr = "something went wrong"
with pytest.raises(AudiverisError, match="failed"):
run_audiveris(tmp_path / "input.pdf", tmp_path / "output")
def test_raises_when_no_mxl_produced(self, tmp_path):
output_dir = tmp_path / "output"
output_dir.mkdir()
with (
patch("python.sheet_music_ocr.audiveris.find_audiveris", return_value="audiveris"),
patch("python.sheet_music_ocr.audiveris.subprocess.run") as mock_run,
):
mock_run.return_value.returncode = 0
with pytest.raises(AudiverisError, match=r"no \.mxl output"):
run_audiveris(tmp_path / "input.pdf", output_dir)
def test_returns_mxl_path(self, tmp_path):
output_dir = tmp_path / "output"
output_dir.mkdir()
mxl = output_dir / "score.mxl"
make_mxl(mxl)
with (
patch("python.sheet_music_ocr.audiveris.find_audiveris", return_value="audiveris"),
patch("python.sheet_music_ocr.audiveris.subprocess.run") as mock_run,
):
mock_run.return_value.returncode = 0
result = run_audiveris(tmp_path / "input.pdf", output_dir)
assert result == mxl
class TestCli:
def test_missing_input_file(self, tmp_path):
result = runner.invoke(app, ["convert", str(tmp_path / "nonexistent.pdf")])
assert result.exit_code == 1
assert "does not exist" in result.output
def test_unsupported_format(self, tmp_path):
bad_file = tmp_path / "music.bmp"
bad_file.touch()
result = runner.invoke(app, ["convert", str(bad_file)])
assert result.exit_code == 1
assert "Unsupported format" in result.output
def test_supported_extensions_complete(self):
assert ".pdf" in SUPPORTED_EXTENSIONS
assert ".png" in SUPPORTED_EXTENSIONS
assert ".jpg" in SUPPORTED_EXTENSIONS
assert ".jpeg" in SUPPORTED_EXTENSIONS
assert ".tiff" in SUPPORTED_EXTENSIONS
def test_successful_conversion(self, tmp_path):
input_file = tmp_path / "score.pdf"
input_file.touch()
output_file = tmp_path / "score.mxml"
mxl_path = tmp_path / "tmp_mxl" / "score.mxl"
mxl_path.parent.mkdir()
make_mxl(mxl_path, b"")
with patch("python.sheet_music_ocr.main.run_audiveris", return_value=mxl_path):
result = runner.invoke(app, ["convert", str(input_file), "-o", str(output_file)])
assert result.exit_code == 0
assert output_file.exists()
assert "Written" in result.output
def test_default_output_path(self, tmp_path):
input_file = tmp_path / "score.png"
input_file.touch()
mxl_path = tmp_path / "tmp_mxl" / "score.mxl"
mxl_path.parent.mkdir()
make_mxl(mxl_path)
with patch("python.sheet_music_ocr.main.run_audiveris", return_value=mxl_path):
result = runner.invoke(app, ["convert", str(input_file)])
assert result.exit_code == 0
assert (tmp_path / "score.mxml").exists()
class TestReviewMxml:
def test_raises_when_no_api_key(self, tmp_path, monkeypatch):
mxml = tmp_path / "score.mxml"
mxml.write_text("")
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
with pytest.raises(ReviewError, match="ANTHROPIC_API_KEY"):
review_mxml(mxml, LLMProvider.CLAUDE)
def test_raises_when_no_openai_key(self, tmp_path, monkeypatch):
mxml = tmp_path / "score.mxml"
mxml.write_text("")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
with pytest.raises(ReviewError, match="OPENAI_API_KEY"):
review_mxml(mxml, LLMProvider.OPENAI)
def test_claude_success(self, tmp_path, monkeypatch):
mxml = tmp_path / "score.mxml"
mxml.write_text("")
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
corrected = ""
mock_response = httpx.Response(
200,
json={"content": [{"text": corrected}]},
request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"),
)
with patch("python.sheet_music_ocr.review.httpx.post", return_value=mock_response):
result = review_mxml(mxml, LLMProvider.CLAUDE)
assert result == corrected
def test_openai_success(self, tmp_path, monkeypatch):
mxml = tmp_path / "score.mxml"
mxml.write_text("")
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
corrected = ""
mock_response = httpx.Response(
200,
json={"choices": [{"message": {"content": corrected}}]},
request=httpx.Request("POST", "https://api.openai.com/v1/chat/completions"),
)
with patch("python.sheet_music_ocr.review.httpx.post", return_value=mock_response):
result = review_mxml(mxml, LLMProvider.OPENAI)
assert result == corrected
def test_claude_api_error(self, tmp_path, monkeypatch):
mxml = tmp_path / "score.mxml"
mxml.write_text("")
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
mock_response = httpx.Response(
500,
text="Internal Server Error",
request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"),
)
with (
patch("python.sheet_music_ocr.review.httpx.post", return_value=mock_response),
pytest.raises(ReviewError, match="Claude API error"),
):
review_mxml(mxml, LLMProvider.CLAUDE)
def test_openai_api_error(self, tmp_path, monkeypatch):
mxml = tmp_path / "score.mxml"
mxml.write_text("")
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
mock_response = httpx.Response(
429,
text="Rate limited",
request=httpx.Request("POST", "https://api.openai.com/v1/chat/completions"),
)
with (
patch("python.sheet_music_ocr.review.httpx.post", return_value=mock_response),
pytest.raises(ReviewError, match="OpenAI API error"),
):
review_mxml(mxml, LLMProvider.OPENAI)
class TestReviewCli:
def test_missing_input_file(self, tmp_path):
result = runner.invoke(app, ["review", str(tmp_path / "nonexistent.mxml")])
assert result.exit_code == 1
assert "does not exist" in result.output
def test_wrong_extension(self, tmp_path):
bad_file = tmp_path / "score.pdf"
bad_file.touch()
result = runner.invoke(app, ["review", str(bad_file)])
assert result.exit_code == 1
assert ".mxml" in result.output
def test_successful_review(self, tmp_path, monkeypatch):
mxml = tmp_path / "score.mxml"
mxml.write_text("")
output = tmp_path / "corrected.mxml"
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
corrected = ""
mock_response = httpx.Response(
200,
json={"content": [{"text": corrected}]},
request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"),
)
with patch("python.sheet_music_ocr.review.httpx.post", return_value=mock_response):
result = runner.invoke(app, ["review", str(mxml), "-o", str(output)])
assert result.exit_code == 0
assert "Reviewed" in result.output
assert output.read_text() == corrected
def test_overwrites_input_by_default(self, tmp_path, monkeypatch):
mxml = tmp_path / "score.mxml"
mxml.write_text("")
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
corrected = ""
mock_response = httpx.Response(
200,
json={"content": [{"text": corrected}]},
request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"),
)
with patch("python.sheet_music_ocr.review.httpx.post", return_value=mock_response):
result = runner.invoke(app, ["review", str(mxml)])
assert result.exit_code == 0
assert mxml.read_text() == corrected