test: add api fixtures

This commit is contained in:
2026-04-10 08:45:24 +02:00
parent 60732f2986
commit 0f1f5b418a
23 changed files with 1324 additions and 483 deletions
+4
View File
@@ -0,0 +1,4 @@
{
"syncedLyrics": "[00:01.00]s1\n[00:02.00]s2",
"plainLyrics": "p1\np2"
}
+20
View File
@@ -0,0 +1,20 @@
[
{
"id": 1,
"trackName": "My Love",
"artistName": "Westlife",
"albumName": "Coast To Coast",
"duration": 231.847,
"syncedLyrics": "[00:01.00]hello",
"plainLyrics": "hello"
},
{
"id": 2,
"trackName": "My Love (Live)",
"artistName": "Westlife",
"albumName": "Live",
"duration": 262.0,
"syncedLyrics": "",
"plainLyrics": "hello"
}
]
+28
View File
@@ -0,0 +1,28 @@
{
"message": {
"body": {
"macro_calls": {
"track.richsync.get": {
"message": {
"header": {
"status_code": 200
},
"body": {
"richsync": {
"richsync_body": "[{\"ts\": 1.2, \"x\": \"hello\"}, {\"ts\": 2.34, \"x\": \"world\"}]"
}
}
}
},
"track.subtitles.get": {
"message": {
"header": {
"status_code": 404
},
"body": {}
}
}
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"message": {
"body": {
"macro_calls": {
"track.richsync.get": {
"message": {
"header": {
"status_code": 404
},
"body": {}
}
},
"track.subtitles.get": {
"message": {
"header": {
"status_code": 200
},
"body": {
"subtitle_list": [
{
"subtitle": {
"subtitle_body": "[{\"text\": \"hello\", \"time\": {\"total\": 1.1}}, {\"text\": \"world\", \"time\": {\"total\": 2.22}}]"
}
}
]
}
}
}
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"message": {
"body": {
"track_list": [
{
"track": {
"commontrack_id": 123,
"track_length": 232,
"has_subtitles": 1,
"has_richsync": 0,
"track_name": "My Love",
"artist_name": "Westlife",
"album_name": "Coast To Coast",
"instrumental": 0
}
}
]
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"lrc": {
"lyric": "[00:01.00]line1\n[00:02.00]line2"
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"result": {
"songs": [
{
"id": 2080607,
"name": "My Love",
"dt": 231941,
"ar": [
{
"name": "Westlife"
}
],
"al": {
"name": "Unbreakable"
}
},
{
"id": 572412968,
"name": "My Love",
"dt": 231000,
"ar": [
{
"name": "Westlife"
}
],
"al": {
"name": "Pure... Love"
}
}
]
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"code": 0,
"data": {
"lyric": "[00:01.00]hello\n[00:02.00]world"
}
}
+33
View File
@@ -0,0 +1,33 @@
{
"code": 0,
"data": {
"list": [
{
"mid": "mid1",
"interval": 232,
"name": "My Love",
"singer": [
{
"name": "Westlife"
}
],
"album": {
"name": "Coast To Coast"
}
},
{
"mid": "mid2",
"interval": 248,
"name": "My Love (Album Version)",
"singer": [
{
"name": "Little Texas"
}
],
"album": {
"name": "Greatest Hits"
}
}
]
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"lyrics": {
"syncType": "LINE_SYNCED",
"lines": [
{"startTimeMs": "1000", "words": "hello"},
{"startTimeMs": "2500", "words": "world"}
]
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"lyrics": {
"syncType": "UNSYNCED",
"lines": [
{"startTimeMs": "0", "words": "plain one"},
{"startTimeMs": "0", "words": "plain two"}
]
}
}
+460 -78
View File
@@ -1,19 +1,40 @@
from dataclasses import replace
import asyncio
import json
from pathlib import Path
from typing import Callable
import httpx
import pytest
from lrx_cli.authenticators import create_authenticators
from lrx_cli.cache import CacheEngine
from lrx_cli.config import AppConfig, load_config
from lrx_cli.core import LrcManager
from lrx_cli.fetchers import FetcherMethodType
from lrx_cli.models import TrackMeta
from tests.marks import (
requires_musixmatch_token,
requires_qq_music,
requires_spotify,
from lrx_cli.fetchers import FetcherMethodType, create_fetchers
from lrx_cli.fetchers.lrclib import LrclibFetcher, _parse_lrclib_response
from lrx_cli.fetchers.lrclib_search import (
LrclibSearchFetcher,
_parse_lrclib_search_results,
)
from lrx_cli.fetchers.musixmatch import (
MusixmatchFetcher,
MusixmatchSpotifyFetcher,
_parse_mxm_macro,
_parse_mxm_search,
)
from lrx_cli.fetchers.netease import (
NeteaseFetcher,
_parse_netease_lyrics,
_parse_netease_search,
)
from lrx_cli.fetchers.qqmusic import QQMusicFetcher, _parse_qq_lyrics, _parse_qq_search
from lrx_cli.fetchers.spotify import SpotifyFetcher, _parse_spotify_lyrics
from lrx_cli.lrc import LRCData
from lrx_cli.models import CacheStatus, TrackMeta
from tests.marks import requires_musixmatch_token, requires_qq_music, requires_spotify
SAMPLE_SPOTIFY_TRACK: TrackMeta = TrackMeta(
SAMPLE_TRACK = TrackMeta(
title="One Last Kiss",
artist="Hikaru Utada",
album="One Last Kiss",
@@ -22,86 +43,152 @@ SAMPLE_SPOTIFY_TRACK: TrackMeta = TrackMeta(
url="https://open.spotify.com/track/5RhWszHMSKzb7KiXk4Ae0M",
)
SAMPLE_SPOTIFY_TRACK_ALBUM_MODIFIED = replace(SAMPLE_SPOTIFY_TRACK, album="BADモード")
SAMPLE_SPOTIFY_TRACK_ARTIST_MODIFIED = replace(
SAMPLE_SPOTIFY_TRACK, artist="宇多田ヒカル"
SAMPLE_TRACK_ALBUM_MODIFIED = replace(SAMPLE_TRACK, album="BADモード")
SAMPLE_TRACK_ARTIST_MODIFIED = replace(SAMPLE_TRACK, artist="宇多田ヒカル")
SAMPLE_TRACK_ALBUM_ARTIST_MODIFIED = replace(
SAMPLE_TRACK,
artist="宇多田ヒカル",
album="BADモード",
)
SAMPLE_SPOTIFY_TRACK_ALBUM_ARTIST_MODIFIED = replace(
SAMPLE_SPOTIFY_TRACK, artist="宇多田ヒカル", album="BADモード"
)
_FIXTURE_DIR = Path(__file__).parent / "fixtures" / "fetchers"
_NETWORK_TIMEOUT = 20.0
ParserFunc = Callable[[dict], LRCData | None]
@pytest.fixture
def lrc_manager(tmp_path: Path) -> LrcManager:
"""LrcManager with empty credentials (no auth required)."""
return LrcManager(str(tmp_path / "cache.db"), AppConfig())
@pytest.fixture
def cred_lrc_manager(tmp_path: Path) -> LrcManager:
"""LrcManager with credentials from config.toml (for CI/network tests)."""
return LrcManager(str(tmp_path / "cache.db"), load_config())
def _fetch_and_assert(
@pytest.fixture
def fetcher_runtime_anonymous(tmp_path: Path):
cfg = AppConfig()
cache = CacheEngine(str(tmp_path / "network-anon-cache.db"))
authenticators = create_authenticators(cache, cfg)
fetchers = create_fetchers(cache, authenticators, cfg)
return fetchers, cfg
@pytest.fixture
def fetcher_runtime_credentialed(tmp_path: Path):
cfg = load_config()
cache = CacheEngine(str(tmp_path / "network-cred-cache.db"))
authenticators = create_authenticators(cache, cfg)
fetchers = create_fetchers(cache, authenticators, cfg)
return fetchers, cfg
def _load_fixture(name: str) -> dict | list:
return json.loads((_FIXTURE_DIR / name).read_text(encoding="utf-8"))
def _assert_shape(actual: object, fixture: object) -> None:
"""Assert actual payload contains fixture structure recursively.
- dict: all fixture keys must exist with matching nested shape
- list: actual must contain at least fixture length and each indexed shape must match
- scalar: runtime type must match fixture type
"""
if isinstance(fixture, dict):
assert isinstance(actual, dict)
for key, value in fixture.items():
assert key in actual
_assert_shape(actual[key], value)
return
if isinstance(fixture, list):
assert isinstance(actual, list)
assert len(actual) >= len(fixture)
for idx, value in enumerate(fixture):
_assert_shape(actual[idx], value)
return
if fixture is None:
return
assert isinstance(actual, type(fixture))
def _fetch_with_method(
lrc_manager: LrcManager,
method: FetcherMethodType,
expect_fail: bool = False,
bypass_cache: bool = True,
) -> None:
result = lrc_manager.fetch_for_track(
SAMPLE_SPOTIFY_TRACK, force_method=method, bypass_cache=bypass_cache
*,
bypass_cache: bool = False,
):
return lrc_manager.fetch_for_track(
SAMPLE_TRACK,
force_method=method,
bypass_cache=bypass_cache,
)
if expect_fail:
assert result is None
else:
assert result is not None
assert result.status == "SUCCESS_SYNCED"
assert result.lyrics is not None
def test_cache_search_fetcher_without_cache(lrc_manager: LrcManager):
_fetch_and_assert(lrc_manager, "cache-search", expect_fail=True, bypass_cache=False)
# Cache-search fetcher behavior
def test_cache_search_no_cache_fails(lrc_manager: LrcManager):
result = _fetch_with_method(lrc_manager, "cache-search", bypass_cache=False)
assert result is None
def test_cache_search_exact_hit(lrc_manager: LrcManager):
expected = "[00:00.01]lyrics"
lrc_manager.manual_insert(SAMPLE_TRACK, expected)
result = lrc_manager.fetch_for_track(
SAMPLE_TRACK,
force_method="cache-search",
bypass_cache=False,
)
assert result is not None
assert result.lyrics is not None
assert result.lyrics.to_text() == expected
@pytest.mark.parametrize(
"query_track",
[
pytest.param(SAMPLE_SPOTIFY_TRACK, id="exact_match"),
pytest.param(SAMPLE_SPOTIFY_TRACK_ARTIST_MODIFIED, id="artist_modified"),
pytest.param(SAMPLE_SPOTIFY_TRACK_ALBUM_MODIFIED, id="album_modified"),
pytest.param(
SAMPLE_SPOTIFY_TRACK_ALBUM_ARTIST_MODIFIED, id="album_artist_modified"
),
pytest.param(SAMPLE_TRACK_ARTIST_MODIFIED, id="artist_modified"),
pytest.param(SAMPLE_TRACK_ALBUM_MODIFIED, id="album_modified"),
pytest.param(SAMPLE_TRACK_ALBUM_ARTIST_MODIFIED, id="album_artist_modified"),
],
)
def test_cache_search_fetcher_with_fuzzy_metadata(
lrc_manager: LrcManager, query_track: TrackMeta
):
expected_lrc = "[00:00.01]lyrics"
lrc_manager.manual_insert(SAMPLE_SPOTIFY_TRACK, expected_lrc)
def test_cache_search_fuzzy_hit(lrc_manager: LrcManager, query_track: TrackMeta):
expected = "[00:00.01]lyrics"
lrc_manager.manual_insert(SAMPLE_TRACK, expected)
result = lrc_manager.fetch_for_track(
query_track, force_method="cache-search", bypass_cache=False
query_track,
force_method="cache-search",
bypass_cache=False,
)
assert result is not None
assert result.lyrics is not None
assert result.lyrics.to_text() == expected_lrc
assert result.lyrics.to_text() == expected
def test_cache_search_fetcher_prefer_better_match(lrc_manager: LrcManager):
def test_cache_search_prefer_better_match(lrc_manager: LrcManager):
lrc_manager.manual_insert(
SAMPLE_SPOTIFY_TRACK_ARTIST_MODIFIED, "[00:00.01]artist modified"
SAMPLE_TRACK_ARTIST_MODIFIED,
"[00:00.01]artist modified",
)
lrc_manager.manual_insert(
SAMPLE_SPOTIFY_TRACK_ALBUM_ARTIST_MODIFIED, "[00:00.01]artist+album modified"
SAMPLE_TRACK_ALBUM_ARTIST_MODIFIED,
"[00:00.01]artist+album modified",
)
result = lrc_manager.fetch_for_track(
SAMPLE_SPOTIFY_TRACK, force_method="cache-search", bypass_cache=False
SAMPLE_TRACK,
force_method="cache-search",
bypass_cache=False,
)
assert result is not None
@@ -109,52 +196,347 @@ def test_cache_search_fetcher_prefer_better_match(lrc_manager: LrcManager):
assert result.lyrics.to_text() == "[00:00.01]artist modified"
# API response format for every fetcher
@pytest.mark.network
@pytest.mark.parametrize(
"method, expect_fail",
[
("lrclib", False),
("lrclib-search", False),
("netease", False),
("spotify", True), # requires auth
("qqmusic", True), # requires api
],
)
def test_anonymous_remote_fetchers(
lrc_manager: LrcManager,
method: FetcherMethodType,
expect_fail: bool,
):
_fetch_and_assert(lrc_manager, method, expect_fail)
def test_api_lrclib_response_shape(fetcher_runtime_anonymous):
fetchers, _cfg = fetcher_runtime_anonymous
fetcher = fetchers["lrclib"]
assert isinstance(fetcher, LrclibFetcher)
async def _run() -> dict:
async with httpx.AsyncClient(timeout=_NETWORK_TIMEOUT) as client:
response = await fetcher._api_get(client, SAMPLE_TRACK)
assert response.status_code == 200
payload = response.json()
assert isinstance(payload, dict)
return payload
payload = asyncio.run(_run())
_assert_shape(payload, _load_fixture("lrclib_response.json"))
@pytest.mark.network
def test_api_lrclib_search_response_shape(fetcher_runtime_anonymous):
fetchers, _cfg = fetcher_runtime_anonymous
fetcher = fetchers["lrclib-search"]
assert isinstance(fetcher, LrclibSearchFetcher)
async def _run() -> list[dict]:
async with httpx.AsyncClient(timeout=_NETWORK_TIMEOUT) as client:
items, had_error = await fetcher._api_candidates(client, SAMPLE_TRACK)
assert had_error is False
return items
payload = asyncio.run(_run())
_assert_shape(payload, _load_fixture("lrclib_search_results.json"))
@pytest.mark.network
def test_api_netease_response_shape(fetcher_runtime_anonymous):
fetchers, _cfg = fetcher_runtime_anonymous
fetcher = fetchers["netease"]
assert isinstance(fetcher, NeteaseFetcher)
async def _run() -> tuple[dict, dict]:
async with httpx.AsyncClient(timeout=_NETWORK_TIMEOUT) as client:
search = await fetcher._api_search_track(client, SAMPLE_TRACK, 5)
lyric = await fetcher._api_lyric_track(client, SAMPLE_TRACK, 5)
assert isinstance(search, dict)
assert isinstance(lyric, dict)
return search, lyric
search_payload, lyric_payload = asyncio.run(_run())
_assert_shape(search_payload, _load_fixture("netease_search.json"))
_assert_shape(lyric_payload, _load_fixture("netease_lyrics.json"))
@pytest.mark.network
@requires_spotify
def test_spotify_fetcher(cred_lrc_manager: LrcManager):
_fetch_and_assert(cred_lrc_manager, "spotify")
def test_api_spotify_response_shape(fetcher_runtime_credentialed):
fetchers, _cfg = fetcher_runtime_credentialed
fetcher = fetchers["spotify"]
assert isinstance(fetcher, SpotifyFetcher)
async def _run() -> dict:
payload = await fetcher._api_lyrics(SAMPLE_TRACK)
assert isinstance(payload, dict)
return payload
payload = asyncio.run(_run())
_assert_shape(payload, _load_fixture("spotify_synced.json"))
@pytest.mark.network
@requires_qq_music
def test_qqmusic_fetcher(cred_lrc_manager: LrcManager):
_fetch_and_assert(cred_lrc_manager, "qqmusic")
def test_api_qqmusic_response_shape(fetcher_runtime_credentialed):
fetchers, _cfg = fetcher_runtime_credentialed
fetcher = fetchers["qqmusic"]
assert isinstance(fetcher, QQMusicFetcher)
async def _run() -> tuple[dict, dict]:
search = await fetcher._api_search(SAMPLE_TRACK, 10)
lyric = await fetcher._api_lyric_track(SAMPLE_TRACK, 10)
assert isinstance(search, dict)
assert isinstance(lyric, dict)
return search, lyric
search_payload, lyric_payload = asyncio.run(_run())
_assert_shape(search_payload, _load_fixture("qq_search.json"))
_assert_shape(lyric_payload, _load_fixture("qq_lyrics.json"))
@pytest.mark.network
def test_musixmatch_anonymous_fetcher(lrc_manager: LrcManager):
# These fetchers should be tested in a single test to share the same usertoken
# Otherwise the second may fail due to rate limits
_fetch_and_assert(lrc_manager, "musixmatch", expect_fail=False)
_fetch_and_assert(lrc_manager, "musixmatch-spotify", expect_fail=False)
def test_api_musixmatch_anonymous_response_shape(fetcher_runtime_anonymous):
"""Anonymous musixmatch calls must share one cache/auth context in this test."""
fetchers, _cfg = fetcher_runtime_anonymous
search_fetcher = fetchers["musixmatch"]
spotify_fetcher = fetchers["musixmatch-spotify"]
assert isinstance(search_fetcher, MusixmatchFetcher)
assert isinstance(spotify_fetcher, MusixmatchSpotifyFetcher)
async def _run() -> tuple[dict, dict, dict]:
search = await search_fetcher._api_search_track(SAMPLE_TRACK)
macro_from_search = await search_fetcher._api_macro_track(SAMPLE_TRACK)
macro_from_spotify = await spotify_fetcher._api_macro_track(SAMPLE_TRACK)
assert isinstance(search, dict)
assert isinstance(macro_from_search, dict)
assert isinstance(macro_from_spotify, dict)
return search, macro_from_search, macro_from_spotify
search_payload, macro_payload, spotify_macro_payload = asyncio.run(_run())
_assert_shape(search_payload, _load_fixture("musixmatch_search.json"))
_assert_shape(macro_payload, _load_fixture("musixmatch_macro_richsync.json"))
_assert_shape(
spotify_macro_payload, _load_fixture("musixmatch_macro_richsync.json")
)
@pytest.mark.network
@requires_musixmatch_token
def test_musixmatch_fetcher(cred_lrc_manager: LrcManager):
_fetch_and_assert(cred_lrc_manager, "musixmatch")
_fetch_and_assert(cred_lrc_manager, "musixmatch-spotify")
def test_api_musixmatch_token_response_shape(fetcher_runtime_credentialed):
fetchers, _cfg = fetcher_runtime_credentialed
search_fetcher = fetchers["musixmatch"]
spotify_fetcher = fetchers["musixmatch-spotify"]
assert isinstance(search_fetcher, MusixmatchFetcher)
assert isinstance(spotify_fetcher, MusixmatchSpotifyFetcher)
async def _run() -> tuple[dict, dict, dict]:
search = await search_fetcher._api_search_track(SAMPLE_TRACK)
macro_from_search = await search_fetcher._api_macro_track(SAMPLE_TRACK)
macro_from_spotify = await spotify_fetcher._api_macro_track(SAMPLE_TRACK)
assert isinstance(search, dict)
assert isinstance(macro_from_search, dict)
assert isinstance(macro_from_spotify, dict)
return search, macro_from_search, macro_from_spotify
search_payload, macro_payload, spotify_macro_payload = asyncio.run(_run())
_assert_shape(search_payload, _load_fixture("musixmatch_search.json"))
_assert_shape(macro_payload, _load_fixture("musixmatch_macro_richsync.json"))
_assert_shape(
spotify_macro_payload, _load_fixture("musixmatch_macro_richsync.json")
)
def test_local_fetcher(lrc_manager: LrcManager):
# Since this not a local track
_fetch_and_assert(lrc_manager, "local", True)
# Parse fixture JSON into real data structures
@pytest.mark.parametrize(
"fixture_name,parser,expected_status",
[
pytest.param(
"spotify_synced.json",
_parse_spotify_lyrics,
"SUCCESS_SYNCED",
id="spotify-synced",
),
pytest.param(
"spotify_unsynced.json",
_parse_spotify_lyrics,
"SUCCESS_UNSYNCED",
id="spotify-unsynced",
),
],
)
def test_parse_spotify_fixture(
fixture_name: str,
parser: ParserFunc,
expected_status: str,
):
payload = _load_fixture(fixture_name)
assert isinstance(payload, dict)
parsed = parser(payload)
assert parsed is not None
assert parsed.detect_sync_status().value == expected_status
if expected_status == "SUCCESS_SYNCED":
assert parsed.to_text() == "[00:01.00]hello\n[00:02.50]world"
else:
assert parsed.to_text() == "[00:00.00]plain one\n[00:00.00]plain two"
def test_parse_qq_search_fixture() -> None:
payload = _load_fixture("qq_search.json")
assert isinstance(payload, dict)
parsed = _parse_qq_search(payload)
assert len(parsed) == 2
assert parsed[0].item == "mid1"
assert parsed[0].title == "My Love"
assert parsed[0].artist == "Westlife"
assert parsed[0].duration_ms == 232000.0
assert parsed[0].album == "Coast To Coast"
assert parsed[1].item == "mid2"
assert parsed[1].title == "My Love (Album Version)"
assert parsed[1].artist == "Little Texas"
assert parsed[1].duration_ms == 248000.0
assert parsed[1].album == "Greatest Hits"
def test_parse_qq_lyrics_fixture() -> None:
payload = _load_fixture("qq_lyrics.json")
assert isinstance(payload, dict)
parsed = _parse_qq_lyrics(payload)
assert parsed is not None
assert len(parsed) == 2
assert parsed.detect_sync_status() == CacheStatus.SUCCESS_SYNCED
def test_parse_lrclib_response_fixture() -> None:
payload = _load_fixture("lrclib_response.json")
assert isinstance(payload, dict)
parsed = _parse_lrclib_response(payload)
assert parsed.synced is not None and parsed.synced.lyrics is not None
assert parsed.unsynced is not None and parsed.unsynced.lyrics is not None
assert parsed.synced.status == CacheStatus.SUCCESS_SYNCED
assert parsed.unsynced.status == CacheStatus.SUCCESS_UNSYNCED
assert parsed.synced.lyrics.to_text() == "[00:01.00]s1\n[00:02.00]s2"
assert parsed.unsynced.lyrics.to_text() == "[00:00.00]p1\n[00:00.00]p2"
def test_parse_lrclib_search_results_fixture() -> None:
payload = _load_fixture("lrclib_search_results.json")
assert isinstance(payload, list)
parsed = _parse_lrclib_search_results(payload)
assert len(parsed) == 2
assert parsed[0].item.get("id") == 1
assert parsed[0].duration_ms == 231847.0
assert parsed[0].is_synced is True
assert parsed[0].title == "My Love"
assert parsed[0].artist == "Westlife"
assert parsed[0].album == "Coast To Coast"
assert parsed[1].item.get("id") == 2
assert parsed[1].duration_ms == 262000.0
assert parsed[1].is_synced is False
assert parsed[1].title == "My Love (Live)"
assert parsed[1].artist == "Westlife"
assert parsed[1].album == "Live"
def test_parse_netease_search_fixture() -> None:
payload = _load_fixture("netease_search.json")
assert isinstance(payload, dict)
parsed = _parse_netease_search(payload)
assert len(parsed) == 2
assert parsed[0].item == 2080607
assert parsed[0].title == "My Love"
assert parsed[0].artist == "Westlife"
assert parsed[0].duration_ms == 231941.0
assert parsed[0].album == "Unbreakable"
assert parsed[1].item == 572412968
assert parsed[1].artist == "Westlife"
assert parsed[1].duration_ms == 231000.0
def test_parse_netease_lyrics_fixture() -> None:
payload = _load_fixture("netease_lyrics.json")
assert isinstance(payload, dict)
parsed = _parse_netease_lyrics(payload)
assert parsed is not None
assert len(parsed) == 2
assert parsed.detect_sync_status() == CacheStatus.SUCCESS_SYNCED
assert parsed.to_text() == "[00:01.00]line1\n[00:02.00]line2"
def test_parse_musixmatch_search_fixture() -> None:
payload = _load_fixture("musixmatch_search.json")
assert isinstance(payload, dict)
parsed = _parse_mxm_search(payload)
assert len(parsed) == 1
assert parsed[0].item == 123
assert parsed[0].is_synced is True
assert parsed[0].title == "My Love"
assert parsed[0].artist == "Westlife"
assert parsed[0].duration_ms == 232000.0
assert parsed[0].album == "Coast To Coast"
def test_parse_musixmatch_macro_fixture() -> None:
payload = _load_fixture("musixmatch_macro_richsync.json")
assert isinstance(payload, dict)
parsed = _parse_mxm_macro(payload)
assert parsed is not None
assert len(parsed) == 2
assert parsed.detect_sync_status() == CacheStatus.SUCCESS_SYNCED
def test_parse_musixmatch_macro_subtitle_fallback_fixture() -> None:
payload = _load_fixture("musixmatch_macro_subtitle.json")
assert isinstance(payload, dict)
parsed = _parse_mxm_macro(payload)
assert parsed is not None
assert len(parsed) == 2
assert parsed.detect_sync_status() == CacheStatus.SUCCESS_SYNCED
assert parsed.to_text() == "[00:01.10]hello\n[00:02.22]world"
# Empty / partial-error response handling
def test_parse_spotify_empty_or_invalid() -> None:
assert _parse_spotify_lyrics({}) is None
assert _parse_spotify_lyrics({"lyrics": {"lines": []}}) is None
def test_parse_qq_search_empty_or_error() -> None:
assert _parse_qq_search({}) == []
assert _parse_qq_search({"code": 1}) == []
assert _parse_qq_search({"code": 0, "data": {"list": []}}) == []
def test_parse_qq_lyrics_empty_or_error() -> None:
assert _parse_qq_lyrics({}) is None
assert _parse_qq_lyrics({"code": 1}) is None
assert _parse_qq_lyrics({"code": 0, "data": {"lyric": ""}}) is None
def test_parse_lrclib_response_empty_or_partial() -> None:
parsed = _parse_lrclib_response({})
assert parsed.synced is not None
assert parsed.unsynced is not None
assert parsed.synced.lyrics is None
assert parsed.unsynced.lyrics is None
parsed_partial = _parse_lrclib_response({"syncedLyrics": "[00:01.00]line"})
assert (
parsed_partial.synced is not None and parsed_partial.synced.lyrics is not None
)
assert parsed_partial.unsynced is not None
def test_parse_netease_empty_or_partial() -> None:
assert _parse_netease_search({}) == []
assert _parse_netease_search({"result": {"songs": []}}) == []
assert _parse_netease_lyrics({}) is None
assert _parse_netease_lyrics({"lrc": {"lyric": ""}}) is None
def test_parse_musixmatch_empty_or_partial() -> None:
assert _parse_mxm_search({}) == []
assert _parse_mxm_search({"message": {"body": {"track_list": []}}}) == []
assert _parse_mxm_macro({}) is None
assert _parse_mxm_macro({"message": {"body": []}}) is None