"""Unit tests for the voice library catalog shaper + ID validation. DB- and network-touching paths (fetch_catalog, install_voice) need a real environment, so we test only the pure transformations here: - Voice ID regex accepts valid piper IDs and rejects path-traversal attempts. - shape_catalog_for_ui projects an HF-shaped catalog dict into the UI list. - _resolve_file_urls picks the right .onnx and .onnx.json paths. """ from __future__ import annotations import pytest from fabledassistant.services import voice_library as lib def test_voice_id_accepts_real_piper_ids(): for vid in [ "en_US-amy-medium", "en_US-ryan-medium", "en_GB-alan-low", "fr_FR-siwis-medium", "de_DE-thorsten-high", "es_AR-daniela-high", "zh_CN-huayan-medium", "ar_JO-kareem-low", "ca_ES-upc_ona-x_low", ]: assert lib._is_valid_voice_id(vid), f"should accept {vid!r}" def test_voice_id_rejects_path_traversal_and_garbage(): # Regex purpose is path-traversal + structurally-malformed prevention, # NOT enforcement of the HF catalog's lowercase-language convention. # Uppercase variants like `EN_US-amy-medium` pass the regex but would # 404 at install time against HF — harmless, no need to over-tighten. for vid in [ "../../etc/passwd", "en_US-amy-medium/../something", "../en_US-amy-medium", "", "en_US-amy", # missing quality "amy-medium", # missing language "en_US--medium", # empty dataset "en_US-amy-large", # invalid quality ".onnx", "en_US-amy-medium.onnx", # has extension; we want the bare id ]: assert not lib._is_valid_voice_id(vid), f"should reject {vid!r}" def test_shape_catalog_projects_expected_fields(monkeypatch, tmp_path): """Catalog dict → list of UI cards, with install state from disk scan.""" # Redirect the install-scan paths to a tmp dir; mark en_US-amy-medium # as installed under /data so we can verify the field flips. user_dir = tmp_path / "data_voices" bundled_dir = tmp_path / "opt_voices" user_dir.mkdir() bundled_dir.mkdir() (user_dir / "en_US-amy-medium.onnx").write_text("model") (user_dir / "en_US-amy-medium.onnx.json").write_text("{}") monkeypatch.setattr(lib, "_USER_VOICES_DIR", user_dir) monkeypatch.setattr(lib, "_BUNDLED_VOICES_DIR", bundled_dir) catalog = { "en_US-amy-medium": { "name": "amy", "language": { "code": "en_US", "name_native": "English", "country_english": "United States", }, "quality": "medium", "num_speakers": 1, "files": { "en/en_US/amy/medium/en_US-amy-medium.onnx": { "size_bytes": 63_500_000, }, "en/en_US/amy/medium/en_US-amy-medium.onnx.json": { "size_bytes": 4_300, }, }, }, "fr_FR-siwis-medium": { "name": "siwis", "language": { "code": "fr_FR", "name_native": "Français", "country_english": "France", }, "quality": "medium", "num_speakers": 1, "files": { "fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx": { "size_bytes": 60_000_000, }, "fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx.json": { "size_bytes": 3_900, }, }, }, } out = lib.shape_catalog_for_ui(catalog) assert len(out) == 2 by_id = {v["id"]: v for v in out} amy = by_id["en_US-amy-medium"] assert amy["name"] == "amy" assert amy["language_code"] == "en_US" assert amy["quality"] == "medium" assert amy["size_bytes"] == 63_500_000 + 4_300 assert amy["installed"] is True assert amy["installed_source"] == "user" siwis = by_id["fr_FR-siwis-medium"] assert siwis["language_code"] == "fr_FR" assert siwis["installed"] is False assert siwis["installed_source"] is None # Sorted by language_code, then name — en_US before fr_FR. assert out[0]["id"] == "en_US-amy-medium" assert out[1]["id"] == "fr_FR-siwis-medium" def test_resolve_file_urls_picks_onnx_and_sidecar(): catalog = { "en_US-amy-medium": { "files": { "en/en_US/amy/medium/en_US-amy-medium.onnx": {"size_bytes": 1}, "en/en_US/amy/medium/en_US-amy-medium.onnx.json": {"size_bytes": 2}, }, } } urls = lib._resolve_file_urls(catalog, "en_US-amy-medium") assert urls["onnx"].endswith("en_US-amy-medium.onnx") assert urls["onnx.json"].endswith("en_US-amy-medium.onnx.json") assert urls["onnx"].startswith("https://huggingface.co/rhasspy/piper-voices/resolve/main/") def test_resolve_file_urls_raises_when_voice_missing(): with pytest.raises(KeyError): lib._resolve_file_urls({}, "en_US-amy-medium") def test_resolve_file_urls_raises_when_files_incomplete(): catalog = { "en_US-amy-medium": { "files": { # Only the .onnx, missing the sidecar — that's malformed. "en/en_US/amy/medium/en_US-amy-medium.onnx": {"size_bytes": 1}, } } } with pytest.raises(ValueError, match="missing"): lib._resolve_file_urls(catalog, "en_US-amy-medium")