tts-voice-creator-clone-and.../tests/test_engine_disconnect.py
mARTin-B78 0551b6651f feat(voices): redesigned My Voices list, working copy, engine-aware TTS availability
My Voices
- Top bar with search, filter toggle (active-count badge), synth-source drawer, group and table buttons;
  filters/sort in a collapsible labelled panel; one-line sort header with select-all/none checkbox
- Groups chip list is a collapsed accordion; bottom bar is Refresh/Sync/Import plus one Actions (N) menu
  (Edit / Audio / Share / Danger), disabled until voices are checked; touch targets stay 44px
- Copy selected works again (copyText was no longer exported), shows "N voice names copied to clipboard"
  and falls back to a copy dialog when the async clipboard is unavailable

TTS / engines
- Health probe works for /v1 card URLs and, behind a LiteLLM gateway, checks the selected model's own
  upstream so stopped containers are not reported as available
- Unhiding a voice publishes a missing entry to the engine's voices.json (no more silent default-voice fallback)
- Status-bar TTS picker also drives the My Voices and STT-test selectors; Use as TTS refreshes the status bar

Includes the engine connect/disconnect, custom TTS, STT footer and magazine auth work that was already
in the working tree, with their tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 04:44:27 +02:00

102 lines
6.6 KiB
Python

"""Disconnect gates inference without destroying saved engine configuration."""
import asyncio
import os
import tempfile
import unittest
from unittest.mock import AsyncMock, Mock, patch
os.environ.setdefault('CONFIG_DIR', tempfile.mkdtemp(prefix='ttvc-disconnect-tests-'))
from fastapi import HTTPException
from core.provider_health import engine_disconnected, provider_request_params
from routes import docker, stt, tts
class EngineDisconnectTests(unittest.TestCase):
def test_legacy_native_and_cross_role_endpoint_matching(self):
self.assertTrue(engine_disconnected({'engine_disabled': {'ollama': True}}, 'http://localhost:11434/v1', 'llm'))
self.assertTrue(engine_disconnected({'engine_disabled': {'ollama': True}}, 'http://host.docker.internal:11434/v1', 'llm'))
settings = {'engine_disabled': {'litellm': True}, 'engine_local_urls': {'litellm': 'http://gateway'}}
self.assertFalse(engine_disconnected(settings, 'http://gateway', 'tts'))
self.assertFalse(engine_disconnected(settings, 'http://gateway', 'ocr'))
@patch('routes.tts._backend_health_detail', return_value={'ok': True})
@patch('routes.tts._fetch_backend_voices', return_value=[])
@patch('routes.tts._load_settings')
def test_native_alias_of_disabled_custom_tts_is_unavailable(self, load, voices, health):
load.return_value = {'tts_url': 'http://gateway', 'engine_active': {'tts_url': 'custom-card'}, 'engine_disabled': {'custom-card': True}, 'custom_engine_cards': [{'id': 'custom-card', 'role': 'tts', 'url': 'http://gateway'}]}
payload = asyncio.run(tts.tts_backends())
item = next(item for item in payload['backends'] if item['id'] == 'voice_clone')
self.assertFalse(item['available'])
self.assertTrue(item['disabled'])
self.assertNotIn('http://gateway', [call.args[0] for call in health.call_args_list])
def test_shared_endpoint_uses_selected_identity(self):
settings = {'llm_url': 'http://gateway/v1', 'engine_active': {'llm_url': 'enabled'}, 'engine_local_urls': {'disabled': 'http://gateway', 'enabled': 'http://gateway'}, 'engine_disabled': {'disabled': True}}
self.assertFalse(engine_disconnected(settings, 'http://gateway/v1', 'llm'))
provider_request_params('http://gateway/v1/chat/completions', {}, settings)
settings['engine_active']['llm_url'] = 'disabled'
with self.assertRaisesRegex(ValueError, 'disconnected'):
provider_request_params('http://gateway/v1/chat/completions', {}, settings)
@patch('routes.docker._save_settings')
@patch('routes.docker._load_settings')
def test_disconnect_retains_saved_configuration(self, load, save):
load.return_value = {'llm_url': 'http://gateway/v1', 'llm_model': 'chosen', 'engine_api_keys': {'card': 'dummy'}, 'custom_engine_cards': [{'id': 'card', 'role': 'llm'}]}
request = Mock(json=AsyncMock(return_value={'connected': False}))
asyncio.run(docker.engine_connection('card', request))
saved = save.call_args.args[0]
self.assertEqual(saved['llm_url'], 'http://gateway/v1')
self.assertEqual(saved['llm_model'], 'chosen')
self.assertEqual(saved['engine_api_keys']['card'], 'dummy')
self.assertTrue(saved['engine_disabled']['card'])
@patch('routes.docker._save_settings')
@patch('routes.docker._load_settings')
def test_current_dc_url_overrides_stale_bare_url(self, load, save):
load.return_value = {'llm_url': 'http://current/v1', 'engine_local_urls': {'dc-card': 'http://current', 'card': 'http://stale'}, 'custom_engine_cards': [{'id': 'card', 'role': 'llm'}], 'engine_disabled': {'card': True}}
self.assertTrue(engine_disconnected(load.return_value, 'http://current', 'llm'))
self.assertFalse(engine_disconnected(load.return_value, 'http://stale', 'llm'))
request = Mock(json=AsyncMock(return_value={'connected': False}))
asyncio.run(docker.engine_connection('card', request))
self.assertEqual(save.call_args.args[0]['engine_active']['llm_url'], 'card')
@patch('routes.docker._save_settings')
@patch('routes.docker._load_settings')
def test_disconnect_records_native_default_selection(self, load, save):
load.return_value = {'llm_url': 'http://host.docker.internal:11434/v1'}
request = Mock(json=AsyncMock(return_value={'connected': False}))
asyncio.run(docker.engine_connection('ollama', request))
self.assertEqual(save.call_args.args[0]['engine_active']['llm_url'], 'ollama')
@patch('routes.docker._save_settings')
@patch('routes.docker._load_settings')
def test_disconnect_records_unique_legacy_selection_for_both_roles(self, load, save):
for role, setting in [('llm', 'llm_url'), ('ocr', 'vision_ocr_url')]:
load.return_value = {setting: 'http://lan:14000/v1', 'engine_local_urls': {'dc-card': 'http://lan:14000'}, 'custom_engine_cards': [{'id': 'card', 'role': role}]}
request = Mock(json=AsyncMock(return_value={'connected': False}))
asyncio.run(docker.engine_connection('card', request))
self.assertEqual(save.call_args.args[0]['engine_active'][setting], 'card')
@patch('routes.docker._save_settings')
@patch('routes.docker._load_settings')
def test_disconnect_preserves_other_selection_on_shared_endpoint(self, load, save):
load.return_value = {'llm_url': 'http://gateway/v1', 'engine_active': {'llm_url': 'other'}, 'engine_local_urls': {'card': 'http://gateway', 'other': 'http://gateway'}, 'custom_engine_cards': [{'id': 'card', 'role': 'llm'}, {'id': 'other', 'role': 'llm'}]}
request = Mock(json=AsyncMock(return_value={'connected': False}))
asyncio.run(docker.engine_connection('card', request))
self.assertEqual(save.call_args.args[0]['engine_active']['llm_url'], 'other')
@patch('routes.stt._stt_backend_health')
@patch('routes.stt._load_settings')
def test_disabled_stt_skips_probe_and_rejects_inference(self, load, health):
load.return_value = {'engine_disabled': {'faster-whisper': True}, 'faster_whisper_url': 'http://whisper'}
health.return_value = {'ok': True, 'models': []}
payload = asyncio.run(stt.stt_backends())
item = next(item for item in payload['backends'] if item['id'] == 'faster_whisper')
self.assertFalse(item['available'])
self.assertFalse(item['connected'])
self.assertNotIn('http://whisper', [call.args[0] for call in health.call_args_list])
with self.assertRaises(HTTPException) as caught:
stt._transcribe_audio(None, load.return_value, 'faster_whisper')
self.assertEqual(caught.exception.status_code, 409)