mirror of
https://github.com/home-assistant/core.git
synced 2026-08-17 18:54:06 +01:00
425 lines
13 KiB
Python
425 lines
13 KiB
Python
"""Tests for the Fish Audio TTS entity."""
|
|
|
|
from http import HTTPStatus
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from fishaudio import RateLimitError
|
|
from fishaudio.exceptions import ServerError
|
|
import pytest
|
|
|
|
from homeassistant.components import tts
|
|
from homeassistant.components.fish_audio.const import (
|
|
CONF_BACKEND,
|
|
CONF_LATENCY,
|
|
CONF_SPEED,
|
|
CONF_VOICE_ID,
|
|
DEFAULT_SPEED,
|
|
DOMAIN,
|
|
)
|
|
from homeassistant.components.media_player import (
|
|
ATTR_MEDIA_CONTENT_ID,
|
|
DOMAIN as MP_DOMAIN,
|
|
SERVICE_PLAY_MEDIA,
|
|
)
|
|
from homeassistant.config_entries import ConfigSubentryData
|
|
from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY
|
|
from homeassistant.core import HomeAssistant, ServiceCall
|
|
from homeassistant.core_config import async_process_ha_core_config
|
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
|
|
|
from tests.common import MockConfigEntry, async_mock_service
|
|
from tests.components.tts.common import retrieve_media
|
|
from tests.typing import ClientSessionGenerator
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None:
|
|
"""Mock writing tags."""
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> None:
|
|
"""Mock the TTS cache dir with empty dir."""
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def setup_internal_url(hass: HomeAssistant) -> None:
|
|
"""Set up internal url."""
|
|
await async_process_ha_core_config(
|
|
hass, {"internal_url": "http://example.local:8123"}
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def calls(hass: HomeAssistant) -> list[ServiceCall]:
|
|
"""Mock media player calls."""
|
|
return async_mock_service(hass, MP_DOMAIN, SERVICE_PLAY_MEDIA)
|
|
|
|
|
|
async def test_tts_service_success(
|
|
hass: HomeAssistant,
|
|
mock_fishaudio_client: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test TTS service with successful audio generation."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
# Get the TTS entity
|
|
entity = hass.data[tts.DOMAIN].get_entity("tts.test_voice_test_voice")
|
|
assert entity is not None
|
|
|
|
# Test the TTS audio generation
|
|
extension, data = await entity.async_get_tts_audio(
|
|
message="Hello world",
|
|
language="en",
|
|
options={},
|
|
)
|
|
|
|
# Verify the result
|
|
assert extension == "mp3"
|
|
assert data == b"fake_audio_data"
|
|
|
|
# Verify the client was called
|
|
mock_fishaudio_client.tts.convert.assert_called_once()
|
|
|
|
|
|
async def test_tts_rate_limited(
|
|
hass: HomeAssistant,
|
|
mock_fishaudio_client: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test TTS service with rate limit error."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
# Get the TTS entity
|
|
entity = hass.data[tts.DOMAIN].get_entity("tts.test_voice_test_voice")
|
|
assert entity is not None
|
|
|
|
# Make tts.convert() fail with rate limit error
|
|
mock_fishaudio_client.tts.convert = AsyncMock(
|
|
side_effect=RateLimitError(429, "Rate limited")
|
|
)
|
|
|
|
# Test that the error is raised
|
|
with pytest.raises(HomeAssistantError, match="Rate limited"):
|
|
await entity.async_get_tts_audio(
|
|
message="Hello world",
|
|
language="en",
|
|
options={},
|
|
)
|
|
|
|
# Verify the client was called
|
|
mock_fishaudio_client.tts.convert.assert_called_once()
|
|
|
|
|
|
async def test_tts_missing_voice_id(
|
|
hass: HomeAssistant,
|
|
mock_fishaudio_client: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test TTS service raises ServiceValidationError when voice_id is missing."""
|
|
# Create a config entry with no voice_id
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
data={"api_key": "test-key"},
|
|
unique_id="test_user",
|
|
subentries_data=[
|
|
ConfigSubentryData(
|
|
data={CONF_BACKEND: "s1"}, # Missing CONF_VOICE_ID
|
|
subentry_type="tts",
|
|
title="Test Voice",
|
|
subentry_id="test-sub-id",
|
|
unique_id="test-voice",
|
|
)
|
|
],
|
|
)
|
|
entry.add_to_hass(hass)
|
|
await hass.config_entries.async_setup(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
# Get the TTS entity
|
|
entity = hass.data[tts.DOMAIN].get_entity("tts.test_voice_test_voice")
|
|
assert entity is not None
|
|
|
|
# Test that the error is raised
|
|
with pytest.raises(ServiceValidationError, match="Voice ID not configured"):
|
|
await entity.async_get_tts_audio(
|
|
message="Hello world",
|
|
language="en",
|
|
options={},
|
|
)
|
|
|
|
|
|
async def test_tts_supported_languages(
|
|
hass: HomeAssistant,
|
|
mock_fishaudio_client: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test TTS entity supported languages."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
# Get the TTS entity
|
|
entity = hass.data[tts.DOMAIN].get_entity("tts.test_voice_test_voice")
|
|
assert entity is not None
|
|
|
|
# Verify supported languages
|
|
assert entity.supported_languages == [
|
|
"Any",
|
|
"en",
|
|
"zh",
|
|
"de",
|
|
"ja",
|
|
"ar",
|
|
"fr",
|
|
"es",
|
|
"ko",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("options", "expected_speed"),
|
|
[
|
|
pytest.param({}, DEFAULT_SPEED, id="default"),
|
|
pytest.param({CONF_SPEED: 1.5}, 1.5, id="per_call_faster"),
|
|
pytest.param({CONF_SPEED: 0.75}, 0.75, id="per_call_slower"),
|
|
],
|
|
)
|
|
async def test_tts_speed_option(
|
|
hass: HomeAssistant,
|
|
mock_fishaudio_client: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
options: dict[str, float],
|
|
expected_speed: float,
|
|
) -> None:
|
|
"""Test the speech speed per-call option is forwarded to the client."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
entity = hass.data[tts.DOMAIN].get_entity("tts.test_voice_test_voice")
|
|
assert entity is not None
|
|
|
|
await entity.async_get_tts_audio(
|
|
message="Hello world",
|
|
language="en",
|
|
options=options,
|
|
)
|
|
|
|
assert mock_fishaudio_client.tts.convert.call_args.kwargs["speed"] == expected_speed
|
|
|
|
|
|
async def test_tts_speed_from_subentry(
|
|
hass: HomeAssistant,
|
|
mock_fishaudio_client: AsyncMock,
|
|
) -> None:
|
|
"""Test the configured per-voice speed is used without a per-call option.
|
|
|
|
This is the Assist pipeline path, which does not pass per-call options.
|
|
"""
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
data={CONF_API_KEY: "test-api-key"},
|
|
unique_id="test_user",
|
|
subentries_data=[
|
|
ConfigSubentryData(
|
|
data={
|
|
CONF_VOICE_ID: "voice-123",
|
|
CONF_BACKEND: "s1",
|
|
CONF_LATENCY: "balanced",
|
|
CONF_SPEED: 1.25,
|
|
},
|
|
subentry_type="tts",
|
|
title="Test Voice",
|
|
subentry_id="test-subentry-id",
|
|
unique_id="voice-123-s1",
|
|
)
|
|
],
|
|
)
|
|
entry.add_to_hass(hass)
|
|
await hass.config_entries.async_setup(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
entity = hass.data[tts.DOMAIN].get_entity("tts.test_voice_test_voice")
|
|
assert entity is not None
|
|
|
|
# The configured speed is exposed as a default option so it is part of the cache key.
|
|
assert entity.default_options == {CONF_SPEED: 1.25}
|
|
|
|
await entity.async_get_tts_audio(
|
|
message="Hello world",
|
|
language="en",
|
|
options={},
|
|
)
|
|
|
|
assert mock_fishaudio_client.tts.convert.call_args.kwargs["speed"] == 1.25
|
|
|
|
|
|
@pytest.mark.parametrize("speed", [0.1, 5.0])
|
|
async def test_tts_speed_out_of_range(
|
|
hass: HomeAssistant,
|
|
mock_fishaudio_client: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
speed: float,
|
|
) -> None:
|
|
"""Test an out-of-range per-call speed raises a validation error."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
entity = hass.data[tts.DOMAIN].get_entity("tts.test_voice_test_voice")
|
|
assert entity is not None
|
|
|
|
with pytest.raises(ServiceValidationError):
|
|
await entity.async_get_tts_audio(
|
|
message="Hello world",
|
|
language="en",
|
|
options={CONF_SPEED: speed},
|
|
)
|
|
|
|
mock_fishaudio_client.tts.convert.assert_not_called()
|
|
|
|
|
|
# Service-level integration tests
|
|
|
|
|
|
async def test_tts_service_speak(
|
|
hass: HomeAssistant,
|
|
mock_fishaudio_client: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
hass_client: ClientSessionGenerator,
|
|
calls: list[ServiceCall],
|
|
) -> None:
|
|
"""Test TTS speak service call."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
await hass.services.async_call(
|
|
tts.DOMAIN,
|
|
"speak",
|
|
{
|
|
ATTR_ENTITY_ID: "tts.test_voice_test_voice",
|
|
tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something",
|
|
tts.ATTR_MESSAGE: "Hello world",
|
|
},
|
|
blocking=True,
|
|
)
|
|
|
|
assert len(calls) == 1
|
|
assert (
|
|
await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID])
|
|
== HTTPStatus.OK
|
|
)
|
|
|
|
|
|
async def test_tts_service_speak_with_language(
|
|
hass: HomeAssistant,
|
|
mock_fishaudio_client: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
hass_client: ClientSessionGenerator,
|
|
calls: list[ServiceCall],
|
|
) -> None:
|
|
"""Test TTS speak service call with language parameter."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
await hass.services.async_call(
|
|
tts.DOMAIN,
|
|
"speak",
|
|
{
|
|
ATTR_ENTITY_ID: "tts.test_voice_test_voice",
|
|
tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something",
|
|
tts.ATTR_MESSAGE: "Hola mundo",
|
|
tts.ATTR_LANGUAGE: "es",
|
|
},
|
|
blocking=True,
|
|
)
|
|
|
|
assert len(calls) == 1
|
|
assert (
|
|
await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID])
|
|
== HTTPStatus.OK
|
|
)
|
|
|
|
|
|
async def test_tts_service_speak_server_error(
|
|
hass: HomeAssistant,
|
|
mock_fishaudio_client: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
hass_client: ClientSessionGenerator,
|
|
calls: list[ServiceCall],
|
|
) -> None:
|
|
"""Test TTS speak service call with server error."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
# Get the client from runtime_data and make it fail with ServerError
|
|
entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
|
|
assert entry is not None
|
|
mock_fishaudio_client.tts.convert = AsyncMock(
|
|
side_effect=ServerError(500, "Internal server error")
|
|
)
|
|
|
|
await hass.services.async_call(
|
|
tts.DOMAIN,
|
|
"speak",
|
|
{
|
|
ATTR_ENTITY_ID: "tts.test_voice_test_voice",
|
|
tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something",
|
|
tts.ATTR_MESSAGE: "Test server error",
|
|
},
|
|
blocking=True,
|
|
)
|
|
|
|
assert len(calls) == 1
|
|
assert (
|
|
await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID])
|
|
== HTTPStatus.INTERNAL_SERVER_ERROR
|
|
)
|
|
|
|
|
|
async def test_tts_service_speak_rate_limit_error(
|
|
hass: HomeAssistant,
|
|
mock_fishaudio_client: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
hass_client: ClientSessionGenerator,
|
|
calls: list[ServiceCall],
|
|
) -> None:
|
|
"""Test TTS speak service call with rate limit error."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
# Get the client from runtime_data and make it fail with RateLimitError
|
|
entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
|
|
assert entry is not None
|
|
mock_fishaudio_client.tts.convert = AsyncMock(
|
|
side_effect=RateLimitError(429, "Rate limit exceeded")
|
|
)
|
|
|
|
await hass.services.async_call(
|
|
tts.DOMAIN,
|
|
"speak",
|
|
{
|
|
ATTR_ENTITY_ID: "tts.test_voice_test_voice",
|
|
tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something",
|
|
tts.ATTR_MESSAGE: "Test rate limit error",
|
|
},
|
|
blocking=True,
|
|
)
|
|
|
|
assert len(calls) == 1
|
|
assert (
|
|
await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID])
|
|
== HTTPStatus.INTERNAL_SERVER_ERROR
|
|
)
|