diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..25d0adc --- /dev/null +++ b/.coveragerc @@ -0,0 +1,5 @@ +[run] +branch = True +source = ovos_tts_plugin_server +omit = + */version.py diff --git a/.github/workflows/e2e_tests.yml b/.github/workflows/e2e_tests.yml new file mode 100644 index 0000000..123743b --- /dev/null +++ b/.github/workflows/e2e_tests.yml @@ -0,0 +1,62 @@ +name: End-to-End Tests + +on: + push: + branches: [master] + pull_request: + branches: [master, dev] + +jobs: + e2e-tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - plugin: "ovos-tts-plugin-piper" + # image: "ghcr.io/openvoiceos/piper-tts-server:dev" # TODO: This doesn't exist yet + image: "ghcr.io/mikejgray/piper-tts-server:dev" + - plugin: "ovos-tts-plugin-espeakng" + image: "ghcr.io/openvoiceos/espeakng:dev" + - plugin: "ovos-tts-plugin-mimic" + image: "ghcr.io/openvoiceos/mimic:dev" + - plugin: "ovos-tts-plugin-beepspeak" + image: "ghcr.io/openvoiceos/beepspeak-tts-server:master" + - plugin: "ovos-tts-plugin-sam" + image: "ghcr.io/openvoiceos/sam:dev" + + env: + PLUGIN_TO_TEST: ${{ matrix.plugin }} + + services: + sam: + image: ${{ matrix.image }} + ports: + - 9666:9666 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Cache Python packages + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt', '**/requirements-e2e.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + pip install -U -r requirements.txt + pip install -U --pre -r requirements-e2e.txt + pip install . + + - name: E2E Tests + run: | + pytest tests/e2e.py diff --git a/.github/workflows/publish_alpha.yml b/.github/workflows/publish_alpha.yml index 7ec11af..de7c61a 100644 --- a/.github/workflows/publish_alpha.yml +++ b/.github/workflows/publish_alpha.yml @@ -29,4 +29,4 @@ jobs: alpha_var: VERSION_ALPHA build_var: VERSION_BUILD minor_var: VERSION_MINOR - major_var: VERSION_MAJOR \ No newline at end of file + major_var: VERSION_MAJOR diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 3e380f3..f87f166 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -1,10 +1,49 @@ name: Unit Tests on: - push: + pull_request: + branches: + - master + - dev workflow_dispatch: jobs: - py_build_tests: - uses: neongeckocom/.github/.github/workflows/python_build_tests.yml@master - with: - python_version: "3.8" + unit-tests: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache Python dependencies + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-python-${{ matrix.python-version }}-${{ hashFiles('**/requirements.txt', '**/requirements-dev.txt') }} + restore-keys: | + ${{ runner.os }}-python-${{ matrix.python-version }}- + ${{ runner.os }}-python- + + - name: Install dependencies + run: |- + pip install -r requirements.txt + pip install -r requirements-dev.txt + pip install . + + - name: Lint + run: |- + black --line-length=119 . + bandit -ll -r ovos_tts_plugin_server + flake8 --max-line-length=119 ovos_tts_plugin_server + isort --profile black . + ruff ovos_tts_plugin_server + mypy --ignore-missing-imports --exclude tests ovos_tts_plugin_server + safety check --ignore=58755 --continue-on-error + + - name: Test + run: pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62ccfc8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,162 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ +.DS_Store +.vscode/ diff --git a/ovos_tts_plugin_server/__init__.py b/ovos_tts_plugin_server/__init__.py index 98c4c8a..504d617 100755 --- a/ovos_tts_plugin_server/__init__.py +++ b/ovos_tts_plugin_server/__init__.py @@ -1,24 +1,34 @@ -from typing import Optional -import requests +"""OpenVoiceOS companion plugin for OpenVoiceOS TTS Server.""" import random -from ovos_plugin_manager.templates.tts import TTS, TTSValidator, RemoteTTSException +from typing import Any, Dict, List, Optional, Tuple + +import requests +from ovos_plugin_manager.templates.tts import TTS, RemoteTTSException, TTSValidator +from ovos_utils.log import LOG + +PUBLIC_TTS_SERVERS = [ + "https://pipertts.ziggyai.online", + "https://tts.smartgic.io/piper", +] class OVOSServerTTS(TTS): - public_servers = [ - "https://pipertts.ziggyai.online", - "https://tts.smartgic.io/piper" - ] + """Interface to OVOS TTS server""" + + public_servers: List[str] = PUBLIC_TTS_SERVERS def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs, audio_ext="wav", - validator=OVOSServerTTSValidator(self)) + super().__init__(*args, **kwargs, audio_ext="wav", validator=OVOSServerTTSValidator(self)) + self.log = LOG if not self.verify_ssl: - self.log.warning("SSL verification disabled, this is not secure and should" - "only be used for test systems! Please set up a valid certificate!") + self.log.warning( + "SSL verification disabled, this is not secure and should" + "only be used for test systems! Please set up a valid certificate!" + ) @property def host(self) -> Optional[str]: + """If using a custom server, set the host here, otherwise it defaults to public servers.""" return self.config.get("host", None) @property @@ -28,55 +38,76 @@ def v2(self) -> bool: @property def verify_ssl(self) -> bool: + """Whether or not to verify SSL certificates when connecting to the server. Defaults to True.""" return self.config.get("verify_ssl", True) - def get_tts(self, sentence, wav_file, lang=None, voice=None): - lang = lang or self.lang - voice = voice or self.voice - params = {"lang": lang, "voice": voice} + def get_tts( + self, + sentence, + wav_file, + lang: Optional[str] = None, + voice: Optional[str] = None, + ) -> Tuple[Any, None]: + """Fetch TTS audio using OVOS TTS server. + Language and voice can be overridden, otherwise defaults to config.""" + params: Dict[str, Optional[str]] = { + "lang": lang or self.lang, + "voice": voice or self.voice, + } if not voice or voice == "default": params.pop("voice") if self.host: if isinstance(self.host, str): - servers = [self.host] + servers: List[str] = [self.host] else: servers = self.host else: - servers = self.public_servers - data = self._get_from_servers(params, sentence, servers) - with open(wav_file, "wb") as f: - f.write(data) + servers = self.public_servers + data: bytes = self._fetch_audio_data(params, sentence, servers) + self._write_audio_file(wav_file, data) return wav_file, None - def _get_from_servers(self, params: dict, sentence: str, servers: list): + def _write_audio_file(self, wav_file: str, data: bytes) -> None: + with open(file=wav_file, mode="wb") as f: + f.write(data) + + def _fetch_audio_data(self, params: dict, sentence: str, servers: list) -> bytes: + """Get audio bytes from servers.""" random.shuffle(servers) # Spread the load among all public servers for url in servers: try: if self.v2: - url = f'{url}/v2/synthesize' + url = f"{url}/v2/synthesize" params["utterance"] = sentence else: - url = f'{url}/synthesize/{sentence}' - r = requests.get(url, params=params, verify=self.verify_ssl) + url = f"{url}/synthesize/{sentence}" + r: requests.Response = requests.get(url=url, params=params, verify=self.verify_ssl, timeout=30) if r.ok: return r.content - except: + self.log.error(f"Failed to get audio, response from {url}: {r.text}") + except Exception as err: # pylint: disable=broad-except + self.log.error(f"Failed to get audio from {url}: {err}") continue - raise RemoteTTSException(f"All OVOS TTS servers are down!") + raise RemoteTTSException("All OVOS TTS servers are down!") class OVOSServerTTSValidator(TTSValidator): - def __init__(self, tts): + """Validate settings for OVOS TTS server plugin.""" + + def __init__(self, tts) -> None: # pylint: disable=useless-parent-delegation super(OVOSServerTTSValidator, self).__init__(tts) - def validate_lang(self): - pass + def validate_lang(self) -> None: + """Validate language setting.""" + return - def validate_connection(self): - pass + def validate_connection(self) -> None: + """Validate connection to server.""" + return def get_tts_class(self): + """Return TTS class.""" return OVOSServerTTS -OVOSServerTTSConfig = {} +OVOSServerTTSConfig: Dict[Any, Any] = {} diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..cb66865 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,12 @@ +ovos-classifiers~=0.0.0a40 +pytest +pytest-cov + +black +bandit +flake8 +isort +ruff +mypy +types-requests +safety diff --git a/requirements-e2e.txt b/requirements-e2e.txt new file mode 100644 index 0000000..f65993f --- /dev/null +++ b/requirements-e2e.txt @@ -0,0 +1,11 @@ +ovos-tts-plugin-piper~=0.0.0 +# ovos-tts-plugin-espeakng~=0.0.2 # May be bugged +ovos-tts-plugin-mimic~=0.2.8 +ovos-tts-plugin-beepspeak~=0.0.1a1 +# ovos-tts-plugin-responsivevoice~=0.1.1 # May be bugged + +ovos-tts-server~=0.0.3a10 + +pytest +pytest-cov +ovos-classifiers~=0.0.0a40 diff --git a/requirements.txt b/requirements.txt index a5e66b9..dd07ffe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -ovos-plugin-manager>=0.0.5 \ No newline at end of file +ovos-plugin-manager>=0.0.5 +requests ~= 2.31.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..d1d768f --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[tool:pytest] +addopts = --cov=ovos_tts_plugin_server --cov-report=term --cov-report=html +testpaths = + tests diff --git a/setup.py b/setup.py index 6c9fef2..6668868 100755 --- a/setup.py +++ b/setup.py @@ -1,28 +1,28 @@ #!/usr/bin/env python3 import os + from setuptools import setup BASEDIR = os.path.abspath(os.path.dirname(__file__)) def get_version(): - """ Find the version of the package""" + """Find the version of the package""" version = None - version_file = os.path.join(BASEDIR, 'ovos_tts_plugin_server', 'version.py') + version_file = os.path.join(BASEDIR, "ovos_tts_plugin_server", "version.py") major, minor, build, alpha = (None, None, None, None) with open(version_file) as f: for line in f: - if 'VERSION_MAJOR' in line: - major = line.split('=')[1].strip() - elif 'VERSION_MINOR' in line: - minor = line.split('=')[1].strip() - elif 'VERSION_BUILD' in line: - build = line.split('=')[1].strip() - elif 'VERSION_ALPHA' in line: - alpha = line.split('=')[1].strip() + if "VERSION_MAJOR" in line: + major = line.split("=")[1].strip() + elif "VERSION_MINOR" in line: + minor = line.split("=")[1].strip() + elif "VERSION_BUILD" in line: + build = line.split("=")[1].strip() + elif "VERSION_ALPHA" in line: + alpha = line.split("=")[1].strip() - if ((major and minor and build and alpha) or - '# END_VERSION_BLOCK' in line): + if (major and minor and build and alpha) or "# END_VERSION_BLOCK" in line: break version = f"{major}.{minor}.{build}" if alpha and int(alpha) > 0: @@ -31,14 +31,13 @@ def get_version(): def required(requirements_file): - """ Read requirements file and remove comments and empty lines. """ - with open(os.path.join(BASEDIR, requirements_file), 'r') as f: + """Read requirements file and remove comments and empty lines.""" + with open(os.path.join(BASEDIR, requirements_file), "r") as f: requirements = f.read().splitlines() - if 'MYCROFT_LOOSE_REQUIREMENTS' in os.environ: - print('USING LOOSE REQUIREMENTS!') - requirements = [r.replace('==', '>=').replace('~=', '>=') for r in requirements] - return [pkg for pkg in requirements - if pkg.strip() and not pkg.startswith("#")] + if "MYCROFT_LOOSE_REQUIREMENTS" in os.environ: + print("USING LOOSE REQUIREMENTS!") + requirements = [r.replace("==", ">=").replace("~=", ">=") for r in requirements] + return [pkg for pkg in requirements if pkg.strip() and not pkg.startswith("#")] def get_description(): @@ -46,42 +45,39 @@ def get_description(): long_description = f.read() return long_description -PLUGIN_ENTRY_POINT = 'ovos-tts-plugin-server = ovos_tts_plugin_server:OVOSServerTTS' -CONFIG_ENTRY_POINT = 'ovos-tts-plugin-server.config = ovos_tts_plugin_server:OVOSServerTTSConfig' + +PLUGIN_ENTRY_POINT = "ovos-tts-plugin-server = ovos_tts_plugin_server:OVOSServerTTS" +CONFIG_ENTRY_POINT = "ovos-tts-plugin-server.config = ovos_tts_plugin_server:OVOSServerTTSConfig" setup( - name='ovos-tts-plugin-server', + name="ovos-tts-plugin-server", version=get_version(), - description='ovos tts server plugin for mycroft', + description="ovos tts server plugin for mycroft", long_description=get_description(), long_description_content_type="text/markdown", - url='https://github.com/OpenVoiceOS/ovos-tts-server-plugin', - author='JarbasAi', - author_email='jarbasai@mailfence.com', - license='Apache-2.0', - packages=['ovos_tts_plugin_server'], + url="https://github.com/OpenVoiceOS/ovos-tts-server-plugin", + author="JarbasAi", + author_email="jarbasai@mailfence.com", + license="Apache-2.0", + packages=["ovos_tts_plugin_server"], install_requires=required("requirements.txt"), zip_safe=True, include_package_data=True, classifiers=[ - 'Development Status :: 3 - Alpha', - 'Intended Audience :: Developers', - 'Topic :: Text Processing :: Linguistic', - 'License :: OSI Approved :: Apache Software License', - - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.0', - 'Programming Language :: Python :: 3.1', - 'Programming Language :: Python :: 3.2', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Topic :: Text Processing :: Linguistic", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ], - keywords='mycroft OpenVoiceOS OVOS plugin tts', - entry_points={'mycroft.plugin.tts': PLUGIN_ENTRY_POINT, - 'mycroft.plugin.tts.config': CONFIG_ENTRY_POINT} + keywords="mycroft OpenVoiceOS OVOS plugin tts", + entry_points={ + "mycroft.plugin.tts": PLUGIN_ENTRY_POINT, + "mycroft.plugin.tts.config": CONFIG_ENTRY_POINT, + }, ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e.py b/tests/e2e.py new file mode 100644 index 0000000..59f53a9 --- /dev/null +++ b/tests/e2e.py @@ -0,0 +1,35 @@ +# pylint: disable=missing-docstring,redefined-outer-name,protected-access +import os + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from ovos_tts_plugin_server import OVOSServerTTS + + +def requests_retry_session( + retries=3, backoff_factor=0.3, status_forcelist=(500, 502, 503, 504) +): + session = requests.Session() + retry = Retry( + total=retries, + read=retries, + connect=retries, + backoff_factor=backoff_factor, + status_forcelist=status_forcelist, + ) + adapter = HTTPAdapter(max_retries=retry) + session.mount("http://", adapter) + session.mount("https://", adapter) + return session + + +def test_tts_plugin_e2e(): + tts_instance = OVOSServerTTS( + config={"host": "http://localhost:9666"} + ) + tts_instance.get_tts(sentence="Hello%20world", wav_file="test.wav") + assert os.path.exists(path="test.wav") + assert os.path.getsize(filename="test.wav") > 0 + os.remove("test.wav") diff --git a/tests/main_test.py b/tests/main_test.py new file mode 100644 index 0000000..8994e01 --- /dev/null +++ b/tests/main_test.py @@ -0,0 +1,220 @@ +# pylint: disable=missing-docstring,redefined-outer-name,protected-access +from unittest.mock import MagicMock, mock_open, patch + +import pytest +from ovos_utils.log import LOG +from requests.exceptions import RequestException + +from ovos_tts_plugin_server import PUBLIC_TTS_SERVERS, OVOSServerTTS, RemoteTTSException + + +@pytest.fixture +def tts_instance() -> OVOSServerTTS: + return OVOSServerTTS() + + +@pytest.fixture +def tts_instance_factory(): + def create_instance(config): + return OVOSServerTTS(config=config) + + return create_instance + + +def test_initialization(tts_instance): + assert tts_instance.audio_ext == "wav" + assert tts_instance.validator is not None + assert tts_instance.log is LOG + assert tts_instance.host is None + assert tts_instance.v2 is True + assert tts_instance.verify_ssl is True + + +def test_host_property(tts_instance, tts_instance_factory): + # Default behavior - No host set + assert tts_instance.host is None + + # Custom host set + custom_host = "https://customhost.com" + custom_tts_instance = tts_instance_factory({"host": custom_host}) + assert tts_instance.host != custom_tts_instance.host + assert custom_tts_instance.host == custom_host + + +@pytest.mark.parametrize( + "host,expected", [(None, True), ("https://customhost.com", False)] +) +def test_v2_property(tts_instance, host, expected): + tts_instance.config["host"] = host + assert tts_instance.v2 is expected + + +def test_verify_ssl_property(tts_instance): + # Default behavior - SSL verification enabled + assert tts_instance.verify_ssl is True + + # SSL verification explicitly disabled + tts_instance.config["verify_ssl"] = False + assert tts_instance.verify_ssl is False + + # SSL verification explicitly enabled + tts_instance.config["verify_ssl"] = True + assert tts_instance.verify_ssl is True + + +@patch( + "ovos_tts_plugin_server.OVOSServerTTS._fetch_audio_data", return_value=b"audio data" +) +@patch("ovos_tts_plugin_server.OVOSServerTTS._write_audio_file") +def test_get_tts(mock_write_audio, mock_fetch_audio, tts_instance): + sentence = "test sentence" + wav_file = "test.wav" + result = tts_instance.get_tts(sentence, wav_file) + + mock_fetch_audio.assert_called_once() + mock_write_audio.assert_called_once_with(wav_file, b"audio data") + assert result[0] == wav_file + + +@patch("ovos_tts_plugin_server.requests.get") +def test_fetch_audio_data_success(mock_get, tts_instance): + mock_response = MagicMock() + mock_response.ok = True + mock_response.content = b"audio data" + mock_get.return_value = mock_response + + sentence = "test sentence" + servers = tts_instance.public_servers + data = tts_instance._fetch_audio_data({}, sentence, servers) + + assert data == b"audio data" + mock_get.assert_called() + + +@patch("ovos_tts_plugin_server.requests.get") +def test_fetch_audio_data_failure(mock_get, tts_instance): + mock_response = MagicMock() + mock_response.ok = False + mock_get.return_value = mock_response + + sentence = "test sentence" + servers = tts_instance.public_servers + + with pytest.raises(RemoteTTSException): + tts_instance._fetch_audio_data({}, sentence, servers) + + assert mock_get.call_count == len(servers) # Ensure all servers are tried + + +@patch("ovos_tts_plugin_server.requests.get", side_effect=RequestException) +def test_fetch_audio_data_exception(mock_get, tts_instance): + sentence = "test sentence" + servers = tts_instance.public_servers + + with pytest.raises(RemoteTTSException): + tts_instance._fetch_audio_data({}, sentence, servers) + + assert mock_get.call_count == len(servers) # Ensure all servers are tried + + +@patch("ovos_utils.log.LOG.warning") +def test_logged_warning(mock_log_warning, tts_instance_factory): + # Create an instance with the specific configuration using the factory fixture + tts_instance_factory({"verify_ssl": False}) + + # Assertions to check the behavior and logging + assert mock_log_warning.call_count == 1 + assert mock_log_warning.call_args[0][0] == ( + "SSL verification disabled, this is not secure and should" + "only be used for test systems! Please set up a valid certificate!" + ) + + +def test_write_audio_file_success(tts_instance): + mock_data = b"test audio data" + mock_file_path = "test.wav" + + # Patch the built-in open function + with patch("builtins.open", mock_open()) as mocked_file: + tts_instance._write_audio_file(mock_file_path, mock_data) + + # Check if open was called correctly + mocked_file.assert_called_with(file=mock_file_path, mode="wb") + + # Check if the correct data was written to the file + mocked_file.return_value.write.assert_called_with(mock_data) + + +def test_validator(tts_instance): + assert tts_instance.validator.validate_lang() is None + assert tts_instance.validator.validate_connection() is None + assert tts_instance.validator.get_tts_class() == OVOSServerTTS + + +@patch( + "ovos_tts_plugin_server.OVOSServerTTS._fetch_audio_data", return_value=b"audio data" +) +@patch("ovos_tts_plugin_server.OVOSServerTTS._write_audio_file") +def test_get_tts_param_change(_, fetch_audio_data, tts_instance): + tts_instance.get_tts( + sentence="test", wav_file="test.wav", lang="en-us", voice="default" + ) + fetch_audio_data.assert_called_with({"lang": "en-us"}, "test", PUBLIC_TTS_SERVERS) + fetch_audio_data.reset_mock() + + tts_instance.get_tts(sentence="test", wav_file="test.wav", lang="en-us") + fetch_audio_data.assert_called_with({"lang": "en-us"}, "test", PUBLIC_TTS_SERVERS) + fetch_audio_data.reset_mock() + + tts_instance.get_tts( + sentence="test", wav_file="test.wav", lang="en-us", voice="apope-low" + ) + fetch_audio_data.assert_called_with( + {"lang": "en-us", "voice": "apope-low"}, "test", PUBLIC_TTS_SERVERS + ) + + +@patch( + "ovos_tts_plugin_server.OVOSServerTTS._fetch_audio_data", return_value=b"audio data" +) +@patch("ovos_tts_plugin_server.OVOSServerTTS._write_audio_file") +def test_get_tts_server_lists(_, fetch_audio_data, tts_instance_factory): + # Default behavior - No host set + tts_instance = tts_instance_factory(config={}) + tts_instance.get_tts("test", "test.wav") + fetch_audio_data.assert_called_with( + {"lang": "en-us"}, "test", tts_instance.public_servers + ) + fetch_audio_data.reset_mock() + # Custom host set + custom_host = "https://customhost.com" + tts_instance = tts_instance_factory(config={"host": custom_host}) + tts_instance.get_tts("test", "test.wav") + fetch_audio_data.assert_called_with({"lang": "en-us"}, "test", [custom_host]) + fetch_audio_data.reset_mock() + # Multiple hosts set + custom_hosts = ["https://customhost1.com", "https://customhost2.com"] + tts_instance = tts_instance_factory(config={"host": custom_hosts}) + tts_instance.get_tts("test", "test.wav") + fetch_audio_data.assert_called_with({"lang": "en-us"}, "test", custom_hosts) + + +@patch("requests.get") +@patch("ovos_tts_plugin_server.OVOSServerTTS._write_audio_file") +def test_v2_property_passing(_, mock_requests, tts_instance_factory): + # Default behavior + tts_instance = tts_instance_factory({}) + assert tts_instance.v2 is True + + # Custom host set + tts_instance = tts_instance_factory( + config={"v2": False, "host": "https://customhost.com"} + ) + assert tts_instance.v2 is False + tts_instance.get_tts("test", "test.wav") + mock_requests.assert_called_with( + url="https://customhost.com/synthesize/test", + params={"lang": "en-us"}, + verify=True, + timeout=30, + )