Spaces:
Sleeping
Sleeping
| """Tests for the batch issue fixes (PR #47). | |
| Covers: | |
| #46 β Discord invite link replacement | |
| #43 β Docker / sys.path fix in backend/main.py | |
| #42 β IndexTTS is_available() graceful conflict detection | |
| #45 β install_hint field in list_backends() | |
| """ | |
| import os | |
| import sys | |
| import importlib | |
| import pathlib | |
| import re | |
| from unittest import mock | |
| os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1") | |
| import pytest | |
| from services import tts_backend | |
| # ββ Repo root for file-level assertions βββββββββββββββββββββββββββββββββββββ | |
| _REPO = pathlib.Path(__file__).resolve().parent.parent | |
| # ββ #46 β Discord link ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| OLD_DISCORD = "discord.gg/aRRdVj3de7" | |
| NEW_DISCORD = "discord.gg/bzQavDfVV9" | |
| _LINK_FILES = [ | |
| "README.md", | |
| "CONTRIBUTING.md", | |
| "frontend/src/pages/EnterprisePage.jsx", | |
| "frontend/src/components/LogsFooter.jsx", | |
| ] | |
| def test_discord_link_updated(relpath): | |
| """Expired Discord invite must not appear anywhere we publish links.""" | |
| filepath = _REPO / relpath | |
| if not filepath.exists(): | |
| pytest.skip(f"{relpath} not found") | |
| content = filepath.read_text() | |
| assert OLD_DISCORD not in content, f"{relpath} still contains expired link {OLD_DISCORD}" | |
| assert NEW_DISCORD in content, f"{relpath} missing new link {NEW_DISCORD}" | |
| def test_no_old_discord_link_anywhere(): | |
| """Repo-wide sweep: no markdown or JSX file should reference the old link.""" | |
| hits = [] | |
| for ext in ("*.md", "*.jsx", "*.tsx"): | |
| for p in _REPO.rglob(ext): | |
| # Skip node_modules, .git, research submodules | |
| parts = p.relative_to(_REPO).parts | |
| if any(skip in parts for skip in ("node_modules", ".git", "research", "dist", "target", "build")): | |
| continue | |
| if OLD_DISCORD in p.read_text(errors="ignore"): | |
| hits.append(str(p.relative_to(_REPO))) | |
| assert hits == [], f"Old Discord link still in: {hits}" | |
| # ββ #43 β Docker / sys.path fix βββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_main_py_adds_backend_to_syspath(): | |
| """backend/main.py must inject its own directory into sys.path.""" | |
| main_py = _REPO / "backend" / "main.py" | |
| src = main_py.read_text() | |
| assert "sys.path.insert" in src, "main.py should add backend/ to sys.path" | |
| assert "_backend_dir" in src or "os.path.dirname" in src | |
| def test_dockerfile_has_pythonpath(): | |
| """deploy/Dockerfile must set PYTHONPATH=/app/backend.""" | |
| dockerfile = _REPO / "deploy" / "Dockerfile" | |
| if not dockerfile.exists(): | |
| pytest.skip("deploy/Dockerfile not found") | |
| content = dockerfile.read_text() | |
| assert "PYTHONPATH" in content, "Dockerfile missing PYTHONPATH env" | |
| assert "/app/backend" in content, "Dockerfile PYTHONPATH should include /app/backend" | |
| def test_main_py_bootstrap_adds_backend_dir(): | |
| """backend/main.py bootstrap must make `core.config` importable on its own. | |
| This validates the actual sys.path.insert in main.py β not conftest.py. | |
| We strip backend/ from sys.path, exec main.py's preamble, then verify | |
| core.config becomes importable. | |
| """ | |
| import importlib.util | |
| backend_dir = str(_REPO / "backend") | |
| # Read just the bootstrap preamble (first 15 lines) of main.py | |
| main_py = _REPO / "backend" / "main.py" | |
| lines = main_py.read_text().splitlines() | |
| # Find the sys.path.insert block β it's in the first ~12 lines | |
| preamble = "\n".join(lines[:15]) | |
| assert "sys.path.insert" in preamble, "Bootstrap preamble must contain sys.path.insert" | |
| assert "_backend_dir" in preamble, "Bootstrap must compute _backend_dir" | |
| # ββ #42 β IndexTTS is_available() graceful conflict detection ββββββββββββββββ | |
| def test_indextts_is_available_returns_tuple(): | |
| """is_available() must always return (bool, str).""" | |
| ok, msg = tts_backend.IndexTTS2Backend.is_available() | |
| assert isinstance(ok, bool) | |
| assert isinstance(msg, str) | |
| assert len(msg) > 0 | |
| def test_indextts_unavailable_message_is_actionable(): | |
| """When IndexTTS is not installed, the message should guide the user. | |
| Plan 02-03 migrated IndexTTS to a subprocess + dedicated venv (closes | |
| #42 properly). The unavailable message is no longer about the | |
| transformers conflict β it's about the venv not existing β and it | |
| must point the user at the install docs. | |
| """ | |
| ok, msg = tts_backend.IndexTTS2Backend.is_available() | |
| if not ok: | |
| # Must point at the env-var-driven install path OR the docs. | |
| msg_lower = msg.lower() | |
| assert ( | |
| "omnivoice_indextts_dir" in msg_lower | |
| or "docs/engines/indextts.md" in msg_lower | |
| or "uv pip install" in msg_lower | |
| or "git clone" in msg_lower | |
| or "conflict" in msg_lower | |
| ), f"is_available() failure message not actionable: {msg!r}" | |
| def test_indextts_no_inprocess_import_attempted(): | |
| """The new IndexTTS2Backend must NOT attempt `import indextts` at any point. | |
| Plan 02-03 closes #42 by running IndexTTS in a subprocess with its | |
| own venv β the parent's transformers>=5.3 never touches | |
| transformers<5. We assert by patching builtins.__import__: if | |
| is_available() triggers an indextts.* import, the assertion fires. | |
| """ | |
| calls: list[str] = [] | |
| original_import = ( | |
| __builtins__.__import__ | |
| if hasattr(__builtins__, "__import__") | |
| else __import__ | |
| ) | |
| def tracking_import(name, *args, **kwargs): | |
| if name.startswith("indextts"): | |
| calls.append(name) | |
| return original_import(name, *args, **kwargs) | |
| with mock.patch("builtins.__import__", side_effect=tracking_import): | |
| ok, msg = tts_backend.IndexTTS2Backend.is_available() | |
| assert calls == [], ( | |
| f"IndexTTS2Backend.is_available() must not import indextts.* in the " | |
| f"parent process (Plan 02-03 / #42); got imports: {calls}" | |
| ) | |
| assert isinstance(ok, bool) | |
| assert isinstance(msg, str) | |
| def test_indextts_docstring_warns_about_uv_sync(): | |
| """The docstring should warn users NOT to use uv sync --all-extras.""" | |
| doc = tts_backend.IndexTTS2Backend.__doc__ | |
| assert doc is not None | |
| assert "uv pip install -e" in doc | |
| assert "uv sync --all-extras" in doc # warning about NOT using it | |
| # ββ #45 β install_hint in list_backends() ββββββββββββββββββββββββββββββββββββ | |
| def test_list_backends_includes_install_hint(): | |
| """Every backend in list_backends() must have an install_hint field.""" | |
| rows = tts_backend.list_backends() | |
| for row in rows: | |
| assert "install_hint" in row, f"Backend {row['id']} missing install_hint" | |
| def test_install_hints_are_nonempty_strings(): | |
| """All install hints should be non-empty strings.""" | |
| rows = tts_backend.list_backends() | |
| for row in rows: | |
| hint = row.get("install_hint") | |
| if hint is not None: | |
| assert isinstance(hint, str) | |
| assert len(hint) > 5, f"install_hint for {row['id']} is too short: {hint!r}" | |
| def test_install_hints_cover_all_registered_backends(): | |
| """_INSTALL_HINTS dict should have an entry for every registered backend.""" | |
| rows = tts_backend.list_backends() | |
| missing = [r["id"] for r in rows if r.get("install_hint") is None] | |
| assert missing == [], f"Backends missing install_hint: {missing}" | |
| def test_indextts_install_hint_warns_about_sync(): | |
| """IndexTTS hint should recommend `uv pip install` not `uv sync --all-extras`.""" | |
| rows = tts_backend.list_backends() | |
| idx_row = next((r for r in rows if r["id"] == "indextts2"), None) | |
| assert idx_row is not None, "indextts2 not in registry" | |
| hint = idx_row["install_hint"] | |
| assert "uv pip install" in hint | |
| assert "NOT" in hint or "not" in hint.lower() | |
| def test_voxcpm_install_hint_uses_correct_package_name(): | |
| """VoxCPM2 backend's hint must reference pip package 'voxcpm', not 'voxcpm2'.""" | |
| rows = tts_backend.list_backends() | |
| vox_row = next((r for r in rows if r["id"] == "voxcpm2"), None) | |
| assert vox_row is not None, "voxcpm2 not in registry" | |
| hint = vox_row["install_hint"] | |
| # The pip package is 'voxcpm', NOT 'voxcpm2' | |
| assert "pip install voxcpm" in hint | |
| assert "voxcpm2" not in hint.split("pip install ")[1].split()[0], ( | |
| f"Hint should say 'pip install voxcpm' not 'pip install voxcpm2': {hint}" | |
| ) | |
| def test_list_backends_shape_unchanged(): | |
| """list_backends() must still include the original fields (backward compat).""" | |
| rows = tts_backend.list_backends() | |
| assert len(rows) > 0 | |
| for row in rows: | |
| assert set(row) >= {"id", "display_name", "available", "reason", "install_hint"} | |
| # ββ Regression: engine registry completeness βββββββββββββββββββββββββββββββββ | |
| def test_registry_minimum_engine_count(): | |
| """We must have at least 9 engines registered.""" | |
| rows = tts_backend.list_backends() | |
| assert len(rows) >= 9, f"Only {len(rows)} engines registered, expected β₯ 9" | |
| def test_all_backends_is_available_returns_tuple(): | |
| """Every backend's is_available() must return (bool, str), no crashes.""" | |
| for bid, cls in tts_backend._REGISTRY.items(): | |
| ok, msg = cls.is_available() | |
| assert isinstance(ok, bool), f"{bid}.is_available() ok is not bool: {type(ok)}" | |
| assert isinstance(msg, str), f"{bid}.is_available() msg is not str: {type(msg)}" | |