/
/
/
1"""Test we can parse Yandex Music API objects into Music Assistant models."""
2
3from __future__ import annotations
4
5import json
6import pathlib
7from typing import TYPE_CHECKING, Any, cast
8
9import pytest
10from yandex_music import Album as YandexAlbum
11from yandex_music import Artist as YandexArtist
12from yandex_music import Playlist as YandexPlaylist
13from yandex_music import Track as YandexTrack
14
15from music_assistant.providers.yandex_music.parsers import (
16 classify_album,
17 detect_description_language,
18 parse_album,
19 parse_artist,
20 parse_audiobook,
21 parse_playlist,
22 parse_podcast,
23 parse_podcast_episode,
24 parse_track,
25)
26from music_assistant.providers.yandex_music.provider import YandexMusicProvider
27
28from .conftest import DE_JSON_CLIENT
29
30if TYPE_CHECKING:
31 from syrupy.assertion import SnapshotAssertion
32
33 from .conftest import ProviderStub
34
35FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures"
36ARTIST_FIXTURES = list(FIXTURES_DIR.glob("artists/*.json"))
37ALBUM_FIXTURES = list(FIXTURES_DIR.glob("albums/*.json"))
38TRACK_FIXTURES = list(FIXTURES_DIR.glob("tracks/*.json"))
39PLAYLIST_FIXTURES = list(FIXTURES_DIR.glob("playlists/*.json"))
40PODCAST_FIXTURES = list(FIXTURES_DIR.glob("podcasts/*.json"))
41AUDIOBOOK_FIXTURES = list(FIXTURES_DIR.glob("audiobooks/*.json"))
42
43
44def _load_json(path: pathlib.Path) -> dict[str, Any]:
45 """Load JSON fixture."""
46 with open(path) as f:
47 return cast("dict[str, Any]", json.load(f))
48
49
50def _artist_from_fixture(path: pathlib.Path) -> YandexArtist | None:
51 """Deserialize Yandex Artist from fixture JSON."""
52 data = _load_json(path)
53 return YandexArtist.de_json(data, DE_JSON_CLIENT)
54
55
56def _album_from_fixture(path: pathlib.Path) -> YandexAlbum | None:
57 """Deserialize Yandex Album from fixture JSON."""
58 data = _load_json(path)
59 return YandexAlbum.de_json(data, DE_JSON_CLIENT)
60
61
62def _track_from_fixture(path: pathlib.Path) -> YandexTrack | None:
63 """Deserialize Yandex Track from fixture JSON."""
64 data = _load_json(path)
65 return YandexTrack.de_json(data, DE_JSON_CLIENT)
66
67
68def _playlist_from_fixture(path: pathlib.Path) -> YandexPlaylist | None:
69 """Deserialize Yandex Playlist from fixture JSON."""
70 data = _load_json(path)
71 return YandexPlaylist.de_json(data, DE_JSON_CLIENT)
72
73
74# provider_stub fixture is provided by conftest.py
75
76
77@pytest.mark.parametrize("example", ARTIST_FIXTURES, ids=lambda val: val.stem)
78def test_parse_artist(example: pathlib.Path, provider_stub: ProviderStub) -> None:
79 """Test we can parse artists from fixture JSON."""
80 artist_obj = _artist_from_fixture(example)
81 assert artist_obj is not None
82 result = parse_artist(cast("YandexMusicProvider", provider_stub), artist_obj)
83 assert result.item_id == str(artist_obj.id)
84 assert result.name == (artist_obj.name or "Unknown Artist")
85 assert result.provider == provider_stub.instance_id
86 assert len(result.provider_mappings) == 1
87 mapping = next(iter(result.provider_mappings))
88 assert f"music.yandex.ru/artist/{artist_obj.id}" in (mapping.url or "")
89
90
91def test_parse_artist_with_cover(provider_stub: ProviderStub) -> None:
92 """Test parsing artist with cover image."""
93 path = FIXTURES_DIR / "artists" / "with_cover.json"
94 artist_obj = _artist_from_fixture(path)
95 assert artist_obj is not None
96 result = parse_artist(cast("YandexMusicProvider", provider_stub), artist_obj)
97 assert result.item_id == "200"
98 assert result.name == "Artist With Cover"
99 if artist_obj.cover and artist_obj.cover.uri:
100 assert result.metadata.images is not None
101 assert len(result.metadata.images) == 1
102 assert "avatars.yandex.net" in (result.metadata.images[0].path or "")
103
104
105def test_parse_artist_with_about(provider_stub: ProviderStub) -> None:
106 """parse_artist enriches description and popularity from ArtistAbout."""
107 artist_obj = _artist_from_fixture(FIXTURES_DIR / "artists" / "with_cover.json")
108 assert artist_obj is not None
109
110 about = type(
111 "ArtistAbout",
112 (),
113 {
114 "description": "Singer-songwriter from somewhere.",
115 "stats": type("Stats", (), {"last_month_listeners": 250_000})(),
116 },
117 )()
118
119 result = parse_artist(cast("YandexMusicProvider", provider_stub), artist_obj, about=about)
120 assert result.metadata.description == "Singer-songwriter from somewhere."
121 # English bio: detector can't be confident â leave language unset.
122 assert result.metadata.description_language is None
123 # 250000 // 10000 == 25
124 assert result.metadata.popularity == 25
125
126
127def test_parse_artist_with_russian_about_sets_language(provider_stub: ProviderStub) -> None:
128 """A Cyrillic-dominant artist bio is tagged as ``ru``."""
129 artist_obj = _artist_from_fixture(FIXTURES_DIR / "artists" / "with_cover.json")
130 assert artist_obj is not None
131
132 about = type(
133 "ArtistAbout",
134 (),
135 {
136 "description": "РоÑÑийÑкий иÑполниÑÐµÐ»Ñ Ð¸Ð· СанкÑ-ÐеÑеÑбÑÑга.",
137 "stats": None,
138 },
139 )()
140
141 result = parse_artist(cast("YandexMusicProvider", provider_stub), artist_obj, about=about)
142 assert result.metadata.description == "РоÑÑийÑкий иÑполниÑÐµÐ»Ñ Ð¸Ð· СанкÑ-ÐеÑеÑбÑÑга."
143 assert result.metadata.description_language == "ru"
144
145
146def test_parse_artist_about_missing_fields(provider_stub: ProviderStub) -> None:
147 """parse_artist tolerates ArtistAbout with missing description/stats."""
148 artist_obj = _artist_from_fixture(FIXTURES_DIR / "artists" / "with_cover.json")
149 assert artist_obj is not None
150
151 about = type("ArtistAbout", (), {"description": None, "stats": None})()
152
153 result = parse_artist(cast("YandexMusicProvider", provider_stub), artist_obj, about=about)
154 assert result.metadata.description is None
155 assert result.metadata.popularity is None
156
157
158def test_parse_artist_about_clamps_popularity(provider_stub: ProviderStub) -> None:
159 """parse_artist caps very large monthly listeners at popularity 100."""
160 artist_obj = _artist_from_fixture(FIXTURES_DIR / "artists" / "with_cover.json")
161 assert artist_obj is not None
162
163 about = type(
164 "ArtistAbout",
165 (),
166 {
167 "description": "",
168 "stats": type("Stats", (), {"last_month_listeners": 50_000_000})(),
169 },
170 )()
171
172 result = parse_artist(cast("YandexMusicProvider", provider_stub), artist_obj, about=about)
173 assert result.metadata.popularity == 100
174
175
176@pytest.mark.parametrize("example", ALBUM_FIXTURES, ids=lambda val: val.stem)
177def test_parse_album(example: pathlib.Path, provider_stub: ProviderStub) -> None:
178 """Test we can parse albums from fixture JSON."""
179 album_obj = _album_from_fixture(example)
180 assert album_obj is not None
181 result = parse_album(cast("YandexMusicProvider", provider_stub), album_obj)
182 assert result.item_id == str(album_obj.id)
183 assert result.name
184 assert result.provider == provider_stub.instance_id
185 mapping = next(iter(result.provider_mappings))
186 assert f"music.yandex.ru/album/{album_obj.id}" in (mapping.url or "")
187 if album_obj.year:
188 assert result.year == album_obj.year
189
190
191@pytest.mark.parametrize("example", TRACK_FIXTURES, ids=lambda val: val.stem)
192def test_parse_track(example: pathlib.Path, provider_stub: ProviderStub) -> None:
193 """Test we can parse tracks from fixture JSON."""
194 track_obj = _track_from_fixture(example)
195 assert track_obj is not None
196 result = parse_track(cast("YandexMusicProvider", provider_stub), track_obj)
197 assert result.item_id == str(track_obj.id)
198 assert result.name
199 assert result.duration == (track_obj.duration_ms or 0) // 1000
200 mapping = next(iter(result.provider_mappings))
201 assert f"music.yandex.ru/track/{track_obj.id}" in (mapping.url or "")
202
203
204def test_parse_track_with_artist_and_album(provider_stub: ProviderStub) -> None:
205 """Test parsing track with artist and album."""
206 path = FIXTURES_DIR / "tracks" / "with_artist_and_album.json"
207 track_obj = _track_from_fixture(path)
208 assert track_obj is not None
209 result = parse_track(cast("YandexMusicProvider", provider_stub), track_obj)
210 assert result.item_id == "500"
211 if track_obj.artists:
212 assert len(result.artists) >= 1
213 assert result.artists[0].name == "Track Artist"
214 if track_obj.albums:
215 assert result.album is not None
216 assert result.album.item_id == "20"
217 assert result.album.name == "Track Album"
218
219
220@pytest.mark.parametrize("example", PLAYLIST_FIXTURES, ids=lambda val: val.stem)
221def test_parse_playlist(example: pathlib.Path, provider_stub: ProviderStub) -> None:
222 """Test we can parse playlists from fixture JSON."""
223 playlist_obj = _playlist_from_fixture(example)
224 assert playlist_obj is not None
225 result = parse_playlist(cast("YandexMusicProvider", provider_stub), playlist_obj)
226 owner_id = (
227 str(playlist_obj.owner.uid) if playlist_obj.owner else str(provider_stub.client.user_id)
228 )
229 kind = str(playlist_obj.kind)
230 assert result.item_id == f"{owner_id}:{kind}"
231 assert result.name == (playlist_obj.title or "Unknown Playlist")
232 mapping = next(iter(result.provider_mappings))
233 assert f"music.yandex.ru/users/{owner_id}/playlists/{kind}" in (mapping.url or "")
234
235
236def test_parse_playlist_editable(provider_stub: ProviderStub) -> None:
237 """Test parsing own playlist (editable)."""
238 path = FIXTURES_DIR / "playlists" / "minimal.json"
239 playlist_obj = _playlist_from_fixture(path)
240 assert playlist_obj is not None
241 result = parse_playlist(cast("YandexMusicProvider", provider_stub), playlist_obj)
242 assert result.owner == "Me"
243 assert result.is_editable is True
244
245
246def test_parse_playlist_other_user(provider_stub: ProviderStub) -> None:
247 """Test parsing playlist owned by another user."""
248 path = FIXTURES_DIR / "playlists" / "other_user.json"
249 playlist_obj = _playlist_from_fixture(path)
250 assert playlist_obj is not None
251 result = parse_playlist(cast("YandexMusicProvider", provider_stub), playlist_obj)
252 assert result.item_id == "99999:1"
253 assert result.name == "Shared Playlist"
254 assert result.owner == "Other User"
255 assert result.is_editable is False
256 assert result.metadata.description == "A shared playlist"
257
258
259# --- Snapshot tests ---
260
261
262def _sort_for_snapshot(parsed: dict[str, Any]) -> dict[str, Any]:
263 """Sort lists in parsed dict for deterministic snapshot comparison."""
264 if parsed.get("external_ids"):
265 parsed["external_ids"] = sorted(parsed["external_ids"])
266 if "metadata" in parsed and isinstance(parsed["metadata"], dict):
267 if parsed["metadata"].get("genres"):
268 parsed["metadata"]["genres"] = sorted(parsed["metadata"]["genres"])
269 return parsed
270
271
272@pytest.mark.parametrize("example", ARTIST_FIXTURES, ids=lambda val: val.stem)
273def test_parse_artist_snapshot(
274 example: pathlib.Path,
275 provider_stub: ProviderStub,
276 snapshot: SnapshotAssertion,
277) -> None:
278 """Snapshot test for artist parsing."""
279 artist_obj = _artist_from_fixture(example)
280 assert artist_obj is not None
281 result = parse_artist(cast("YandexMusicProvider", provider_stub), artist_obj)
282 parsed = _sort_for_snapshot(result.to_dict())
283 assert snapshot == parsed
284
285
286@pytest.mark.parametrize("example", ALBUM_FIXTURES, ids=lambda val: val.stem)
287def test_parse_album_snapshot(
288 example: pathlib.Path,
289 provider_stub: ProviderStub,
290 snapshot: SnapshotAssertion,
291) -> None:
292 """Snapshot test for album parsing."""
293 album_obj = _album_from_fixture(example)
294 assert album_obj is not None
295 result = parse_album(cast("YandexMusicProvider", provider_stub), album_obj)
296 parsed = _sort_for_snapshot(result.to_dict())
297 assert snapshot == parsed
298
299
300@pytest.mark.parametrize("example", TRACK_FIXTURES, ids=lambda val: val.stem)
301def test_parse_track_snapshot(
302 example: pathlib.Path,
303 provider_stub: ProviderStub,
304 snapshot: SnapshotAssertion,
305) -> None:
306 """Snapshot test for track parsing."""
307 track_obj = _track_from_fixture(example)
308 assert track_obj is not None
309 result = parse_track(cast("YandexMusicProvider", provider_stub), track_obj)
310 parsed = _sort_for_snapshot(result.to_dict())
311 assert snapshot == parsed
312
313
314@pytest.mark.parametrize("example", PLAYLIST_FIXTURES, ids=lambda val: val.stem)
315def test_parse_playlist_snapshot(
316 example: pathlib.Path,
317 provider_stub: ProviderStub,
318 snapshot: SnapshotAssertion,
319) -> None:
320 """Snapshot test for playlist parsing."""
321 playlist_obj = _playlist_from_fixture(example)
322 assert playlist_obj is not None
323 result = parse_playlist(cast("YandexMusicProvider", provider_stub), playlist_obj)
324 parsed = _sort_for_snapshot(result.to_dict())
325 assert snapshot == parsed
326
327
328# --- classify_album ---
329
330
331@pytest.mark.parametrize(
332 ("meta_type", "type_", "expected"),
333 [
334 ("podcast", None, "podcast"),
335 (None, "podcast", "podcast"),
336 ("Podcast", None, "podcast"),
337 ("podcast_episode", None, "podcast"),
338 ("audiobook", None, "audiobook"),
339 (None, "audiobook", "audiobook"),
340 ("AUDIOBOOK", None, "audiobook"),
341 # audiobook wins over podcast on any field â empirically observed:
342 # Yandex tags audiobooks as meta_type="podcast" + type="audiobook"
343 ("podcast", "audiobook", "audiobook"),
344 ("audiobook", "podcast", "audiobook"),
345 ("audiobook", "music", "audiobook"),
346 # plain music
347 (None, None, "music"),
348 ("music", "album", "music"),
349 ("", "", "music"),
350 ],
351)
352def test_classify_album(
353 meta_type: str | None,
354 type_: str | None,
355 expected: str,
356) -> None:
357 """classify_album maps meta_type/type variants to music/podcast/audiobook."""
358 album_obj = YandexAlbum.de_json(
359 {"id": 1, "title": "x", "meta_type": meta_type, "type": type_},
360 DE_JSON_CLIENT,
361 )
362 assert album_obj is not None
363 assert classify_album(album_obj) == expected
364
365
366# --- Podcast / Audiobook / PodcastEpisode parsers ---
367
368
369@pytest.mark.parametrize("example", PODCAST_FIXTURES, ids=lambda val: val.stem)
370def test_parse_podcast(example: pathlib.Path, provider_stub: ProviderStub) -> None:
371 """parse_podcast extracts basic fields from a podcast-typed album fixture."""
372 album_obj = _album_from_fixture(example)
373 assert album_obj is not None
374 result = parse_podcast(cast("YandexMusicProvider", provider_stub), album_obj)
375 assert result.item_id == str(album_obj.id)
376 assert result.name
377 assert result.provider == provider_stub.instance_id
378 mapping = next(iter(result.provider_mappings))
379 assert f"music.yandex.ru/album/{album_obj.id}" in (mapping.url or "")
380 # publisher resolves from labels[0].name when present
381 if album_obj.labels:
382 first = album_obj.labels[0]
383 label_name = first if isinstance(first, str) else getattr(first, "name", None)
384 if label_name:
385 assert result.publisher == label_name
386 if album_obj.track_count is not None:
387 assert result.total_episodes == album_obj.track_count
388
389
390@pytest.mark.parametrize("example", AUDIOBOOK_FIXTURES, ids=lambda val: val.stem)
391def test_parse_audiobook(example: pathlib.Path, provider_stub: ProviderStub) -> None:
392 """parse_audiobook extracts authors from artists and publisher from labels."""
393 album_obj = _album_from_fixture(example)
394 assert album_obj is not None
395 result = parse_audiobook(cast("YandexMusicProvider", provider_stub), album_obj)
396 assert result.item_id == str(album_obj.id)
397 assert result.name
398 assert result.duration == 0 # filled in later by get_audiobook()
399 # authors come from album artists
400 expected_authors = [a.name for a in (album_obj.artists or []) if a.name]
401 assert list(result.authors) == expected_authors
402 assert list(result.narrators) == []
403
404
405def test_parse_audiobook_fully_played_true(provider_stub: ProviderStub) -> None:
406 """parse_audiobook propagates album.listening_finished=True to fully_played."""
407 album_obj = _album_from_fixture(FIXTURES_DIR / "audiobooks" / "basic.json")
408 assert album_obj is not None
409 album_obj.listening_finished = True
410 result = parse_audiobook(cast("YandexMusicProvider", provider_stub), album_obj)
411 assert result.fully_played is True
412
413
414def test_parse_audiobook_fully_played_false(provider_stub: ProviderStub) -> None:
415 """parse_audiobook propagates album.listening_finished=False to fully_played."""
416 album_obj = _album_from_fixture(FIXTURES_DIR / "audiobooks" / "basic.json")
417 assert album_obj is not None
418 album_obj.listening_finished = False
419 result = parse_audiobook(cast("YandexMusicProvider", provider_stub), album_obj)
420 assert result.fully_played is False
421
422
423def test_parse_audiobook_fully_played_none(provider_stub: ProviderStub) -> None:
424 """parse_audiobook leaves fully_played=None when the flag is missing."""
425 album_obj = _album_from_fixture(FIXTURES_DIR / "audiobooks" / "basic.json")
426 assert album_obj is not None
427 album_obj.listening_finished = None
428 result = parse_audiobook(cast("YandexMusicProvider", provider_stub), album_obj)
429 assert result.fully_played is None
430
431
432def test_parse_podcast_episode(provider_stub: ProviderStub) -> None:
433 """parse_podcast_episode links episode to its parent podcast."""
434 podcast_album = _album_from_fixture(FIXTURES_DIR / "podcasts" / "basic.json")
435 assert podcast_album is not None
436 podcast = parse_podcast(cast("YandexMusicProvider", provider_stub), podcast_album)
437
438 track_obj = _track_from_fixture(FIXTURES_DIR / "podcast_episodes" / "basic.json")
439 assert track_obj is not None
440 episode = parse_podcast_episode(
441 cast("YandexMusicProvider", provider_stub), track_obj, podcast, position=1
442 )
443 assert episode.item_id == str(track_obj.id)
444 assert episode.name == track_obj.title
445 assert episode.position == 1
446 assert episode.duration == (track_obj.duration_ms or 0) // 1000
447 assert episode.podcast is podcast
448 mapping = next(iter(episode.provider_mappings))
449 assert f"music.yandex.ru/track/{track_obj.id}" in (mapping.url or "")
450
451
452# M17: description_language must remain unset on podcast / audiobook / episode.
453# PR #155 narrowed the field to artist bios only. These guards make a
454# silent re-wire of the four extra parsers fail loudly.
455
456
457@pytest.mark.parametrize("example", PODCAST_FIXTURES, ids=lambda val: val.stem)
458def test_parse_podcast_does_not_set_description_language(
459 example: pathlib.Path, provider_stub: ProviderStub
460) -> None:
461 """``description_language`` must stay unset on podcast â PR #155 regression guard."""
462 album_obj = _album_from_fixture(example)
463 assert album_obj is not None
464 result = parse_podcast(cast("YandexMusicProvider", provider_stub), album_obj)
465 assert result.metadata.description_language is None
466
467
468@pytest.mark.parametrize("example", AUDIOBOOK_FIXTURES, ids=lambda val: val.stem)
469def test_parse_audiobook_does_not_set_description_language(
470 example: pathlib.Path, provider_stub: ProviderStub
471) -> None:
472 """``description_language`` must stay unset on audiobook â PR #155 regression guard."""
473 album_obj = _album_from_fixture(example)
474 assert album_obj is not None
475 result = parse_audiobook(cast("YandexMusicProvider", provider_stub), album_obj)
476 assert result.metadata.description_language is None
477
478
479def test_parse_podcast_episode_does_not_set_description_language(
480 provider_stub: ProviderStub,
481) -> None:
482 """``description_language`` must stay unset on podcast-episode â PR #155 regression."""
483 podcast_album = _album_from_fixture(FIXTURES_DIR / "podcasts" / "basic.json")
484 assert podcast_album is not None
485 podcast = parse_podcast(cast("YandexMusicProvider", provider_stub), podcast_album)
486
487 track_obj = _track_from_fixture(FIXTURES_DIR / "podcast_episodes" / "basic.json")
488 assert track_obj is not None
489 episode = parse_podcast_episode(
490 cast("YandexMusicProvider", provider_stub), track_obj, podcast, position=1
491 )
492 assert episode.metadata.description_language is None
493
494
495def test_parse_podcast_episode_inherits_podcast_image(provider_stub: ProviderStub) -> None:
496 """Episode image falls back to parent podcast image when track has none."""
497 podcast_album = _album_from_fixture(FIXTURES_DIR / "podcasts" / "basic.json")
498 assert podcast_album is not None
499 podcast = parse_podcast(cast("YandexMusicProvider", provider_stub), podcast_album)
500 # strip cover on the track so the fallback kicks in
501 track_obj = _track_from_fixture(FIXTURES_DIR / "podcast_episodes" / "basic.json")
502 assert track_obj is not None
503 track_obj.cover_uri = None
504 track_obj.og_image = None
505 episode = parse_podcast_episode(
506 cast("YandexMusicProvider", provider_stub), track_obj, podcast, position=1
507 )
508 assert episode.metadata.images is not None
509 assert episode.metadata.images == podcast.metadata.images
510 # Must be a separate list â mutating one shouldn't affect the other.
511 assert episode.metadata.images is not podcast.metadata.images
512
513
514# -- detect_description_language helper --------------------------------------
515
516
517@pytest.mark.parametrize(
518 ("text", "expected"),
519 [
520 pytest.param(None, None, id="none"),
521 pytest.param("", None, id="empty"),
522 pytest.param(" ", None, id="whitespace"),
523 # Whitespace must be stripped before the 50% share check, otherwise
524 # padding can dilute the Cyrillic share below the threshold even when
525 # the text itself is unambiguously Russian.
526 pytest.param(" РоÑÑийÑкий иÑполниÑелÑ. ", "ru", id="leading-trailing-whitespace-ru"),
527 pytest.param("Singer-songwriter from somewhere.", None, id="english-bio"),
528 pytest.param("РоÑÑийÑкий иÑполниÑÐµÐ»Ñ Ð¸Ð· СанкÑ-ÐеÑеÑбÑÑга.", "ru", id="ru-bio"),
529 pytest.param(
530 'ÐÑÑппа "Ðино" поÑвилаÑÑ Ð² 1982 Ð³Ð¾Ð´Ñ Ð² ÐенингÑаде.',
531 "ru",
532 id="ru-bio-with-punct",
533 ),
534 pytest.param("ÐÑивеÑ!", None, id="too-short-ru-only-6-cyrillic"),
535 pytest.param(
536 "An English bio that mentions ÐоÑква once in passing.",
537 None,
538 id="english-with-stray-cyrillic",
539 ),
540 # Boundary tests for the floor (>= 8 Cyrillic chars).
541 pytest.param("абвгдеÑж", "ru", id="floor-exactly-8-cyrillic"),
542 pytest.param("абвгдеÑ!", None, id="floor-just-below-7-cyrillic"),
543 # Boundary tests for the share (>= 50% Cyrillic).
544 pytest.param("абвгдеÑж01234567", "ru", id="share-exactly-50pct"), # noqa: RUF001
545 pytest.param("абвгдеÑж012345678", None, id="share-just-below-50pct"), # noqa: RUF001
546 # Latin-heavy bio with embedded Cyrillic name must NOT flip to ru.
547 pytest.param(
548 "Bio of Pyotr Tchaikovsky (ÐÑÑÑ ÐлÑÐ¸Ñ Ð§Ð°Ð¹ÐºÐ¾Ð²Ñкий), composer.",
549 None,
550 id="english-bio-with-russian-name",
551 ),
552 # Non-Russian Cyrillic languages must not be tagged ru.
553 pytest.param("УкÑаÑнÑÑкий ÑпÑвак з ÐиÑва.", None, id="ukrainian-bio"),
554 pytest.param(
555 "СпÑвае Ñ ÐенÑÐºÑ Ñ Ð¿Ð°ÐºÑдае Ñлед.", # noqa: RUF001
556 None,
557 id="belarusian-bio",
558 ),
559 pytest.param(
560 "РоÑÑийÑкий певеÑ, Ñодом из ÐиÑва.",
561 None,
562 id="russian-with-ukrainian-spelling",
563 ),
564 ],
565)
566def test_detect_description_language(text: str | None, expected: str | None) -> None:
567 """Cyrillic-dominant Russian text is classified as ru; everything else is None."""
568 assert detect_description_language(text) == expected
569