| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391 |
- # -*- coding: utf-8 -*-
- """
- Tests fuer das VoiceMorph Plugin.
- """
- import asyncio
- import numpy as np
- from pathlib import Path
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from trixy_core.nlp.handler import IntentReceivedData, IntentResult
- from trixy_core.audio_morpher.presets import VoiceMorphPreset
- from plugins.voice_morph.main import VoiceMorphPlugin
- # ============================================================================
- # Fixtures
- # ============================================================================
- def _make_plugin(tmp_path, config_overrides=None) -> tuple[VoiceMorphPlugin, MagicMock]:
- """Erstellt ein VoiceMorphPlugin mit Mock-Application."""
- mock_app = MagicMock()
- mock_app.events = MagicMock()
- mock_app.events.emit = AsyncMock()
- mock_app.events.trigger = AsyncMock()
- mock_app.events.register = MagicMock()
- mock_app.events.unregister = MagicMock()
- mock_app.debug = True
- mock_app.services = MagicMock()
- mock_app.services.get_service = MagicMock(return_value=None)
- config = {
- "enabled": True,
- "active_preset": "test_preset",
- "intensity": 1.0,
- "presets": {
- "test_preset": {
- "label": "Test Preset",
- "pitch": {"semitones": 4.0},
- "speed": {"factor": 0.95},
- },
- "robot": {
- "label": "Robot Stimme",
- "pitch": {"semitones": -2.0},
- "distortion": {"gain": 1.5},
- },
- },
- }
- if config_overrides:
- config.update(config_overrides)
- plugin_path = tmp_path / "voice_morph"
- plugin_path.mkdir(exist_ok=True)
- plugin = VoiceMorphPlugin(
- application=mock_app,
- plugin_path=plugin_path,
- config=config,
- )
- return plugin, mock_app
- def _make_data(**kwargs) -> IntentReceivedData:
- """Erstellt IntentReceivedData mit Defaults."""
- defaults = {
- "intent": "voice_morph_status",
- "satellite_id": "sat-1",
- "room_id": "wohnzimmer",
- "is_authenticated": True,
- }
- defaults.update(kwargs)
- return IntentReceivedData(**defaults)
- def _make_sine_audio(freq: float = 440.0, duration: float = 0.1,
- sample_rate: int = 22050) -> bytes:
- """Generiert Sinus-Audio als int16 hex-encoded bytes."""
- t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)
- audio = (np.sin(2 * np.pi * freq * t) * 16000).astype(np.int16)
- return audio.tobytes().hex()
- # ============================================================================
- # Tests: Plugin Lifecycle
- # ============================================================================
- class TestLifecycle:
- """Tests fuer on_load und on_unload."""
- @pytest.mark.asyncio
- async def test_on_load_registers_handler(self, tmp_path):
- """on_load registriert tts_completed Event-Handler."""
- plugin, mock_app = _make_plugin(tmp_path)
- await plugin.on_load()
- mock_app.events.register.assert_called_once()
- call_args = mock_app.events.register.call_args
- assert call_args[0][0] == "tts_completed"
- @pytest.mark.asyncio
- async def test_on_load_sets_active_preset(self, tmp_path):
- """on_load setzt das konfigurierte Preset."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- assert plugin._active_preset is not None
- assert plugin._active_preset.preset_id == "test_preset"
- assert plugin._active_preset.label == "Test Preset"
- @pytest.mark.asyncio
- async def test_on_load_unknown_preset(self, tmp_path):
- """on_load mit unbekanntem Preset bleibt deaktiviert."""
- plugin, _ = _make_plugin(tmp_path, {"active_preset": "nonexistent"})
- await plugin.on_load()
- assert plugin._active_preset is None
- @pytest.mark.asyncio
- async def test_on_load_no_preset(self, tmp_path):
- """on_load ohne active_preset bleibt deaktiviert."""
- plugin, _ = _make_plugin(tmp_path, {"active_preset": ""})
- await plugin.on_load()
- assert plugin._active_preset is None
- @pytest.mark.asyncio
- async def test_on_load_custom_intensity(self, tmp_path):
- """on_load laedt Intensitaet aus Config."""
- plugin, _ = _make_plugin(tmp_path, {"intensity": 0.75})
- await plugin.on_load()
- assert plugin._intensity == 0.75
- @pytest.mark.asyncio
- async def test_on_unload(self, tmp_path):
- """on_unload entfernt Event-Handler."""
- plugin, mock_app = _make_plugin(tmp_path)
- await plugin.on_load()
- await plugin.on_unload()
- mock_app.events.unregister.assert_called_once()
- @pytest.mark.asyncio
- async def test_loads_custom_presets(self, tmp_path):
- """on_load laedt Presets aus config.json."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- assert plugin._morpher.get_preset("test_preset") is not None
- assert plugin._morpher.get_preset("robot") is not None
- # ============================================================================
- # Tests: Audio Morphing
- # ============================================================================
- class TestAudioMorphing:
- """Tests fuer den _on_tts_completed Handler."""
- @pytest.mark.asyncio
- async def test_morph_modifies_audio(self, tmp_path):
- """Morphing veraendert die Audio-Daten."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- original_hex = _make_sine_audio(440.0, 0.5, 22050)
- event_data = {
- "audio_data": original_hex,
- "sample_rate": 22050,
- "satellite_id": "sat-1",
- }
- await plugin._on_tts_completed("tts_completed", event_data)
- # Audio wurde veraendert
- assert event_data["audio_data"] != original_hex
- # Ist immer noch gueltiges Hex
- bytes.fromhex(event_data["audio_data"])
- @pytest.mark.asyncio
- async def test_morph_skipped_when_disabled(self, tmp_path):
- """Kein Morphing wenn kein Preset aktiv."""
- plugin, _ = _make_plugin(tmp_path, {"active_preset": ""})
- await plugin.on_load()
- original_hex = _make_sine_audio()
- event_data = {"audio_data": original_hex, "sample_rate": 22050}
- await plugin._on_tts_completed("tts_completed", event_data)
- assert event_data["audio_data"] == original_hex
- @pytest.mark.asyncio
- async def test_morph_empty_audio(self, tmp_path):
- """Leeres Audio wird ignoriert."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- event_data = {"audio_data": "", "sample_rate": 22050}
- await plugin._on_tts_completed("tts_completed", event_data)
- assert event_data["audio_data"] == ""
- @pytest.mark.asyncio
- async def test_morph_preserves_other_fields(self, tmp_path):
- """Morphing veraendert nur audio_data, nicht andere Felder."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- event_data = {
- "audio_data": _make_sine_audio(),
- "sample_rate": 22050,
- "satellite_id": "sat-1",
- "provider": "piper",
- "text": "Hallo Welt",
- }
- await plugin._on_tts_completed("tts_completed", event_data)
- assert event_data["satellite_id"] == "sat-1"
- assert event_data["provider"] == "piper"
- assert event_data["text"] == "Hallo Welt"
- assert event_data["sample_rate"] == 22050
- # ============================================================================
- # Tests: Intent-Handler
- # ============================================================================
- class TestIntents:
- """Tests fuer die 6 Voice Morph Intent-Handler."""
- @pytest.mark.asyncio
- async def test_enable(self, tmp_path):
- """voice_morph_enable aktiviert das Preset."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- plugin._active_preset = None # Erst deaktivieren
- result = await plugin.handle_enable(_make_data())
- assert result.success is True
- assert plugin._active_preset is not None
- assert "Test Preset" in result.response_text
- @pytest.mark.asyncio
- async def test_enable_no_preset_configured(self, tmp_path):
- """voice_morph_enable ohne konfiguriertes Preset."""
- plugin, _ = _make_plugin(tmp_path, {"active_preset": ""})
- await plugin.on_load()
- result = await plugin.handle_enable(_make_data())
- assert result.success is False
- @pytest.mark.asyncio
- async def test_disable(self, tmp_path):
- """voice_morph_disable deaktiviert das Morphing."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- result = await plugin.handle_disable(_make_data())
- assert result.success is True
- assert plugin._active_preset is None
- assert "deaktiviert" in result.response_text.lower()
- @pytest.mark.asyncio
- async def test_status_active(self, tmp_path):
- """voice_morph_status zeigt aktives Preset."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- result = await plugin.handle_status(_make_data())
- assert result.success is True
- assert "Test Preset" in result.response_text
- assert "100" in result.response_text # 100 Prozent
- @pytest.mark.asyncio
- async def test_status_inactive(self, tmp_path):
- """voice_morph_status zeigt deaktiviert."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- plugin._active_preset = None
- result = await plugin.handle_status(_make_data())
- assert result.success is True
- assert "deaktiviert" in result.response_text.lower()
- @pytest.mark.asyncio
- async def test_set_preset_by_id(self, tmp_path):
- """voice_morph_set_preset wechselt Preset per ID."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- plugin.save_config = MagicMock()
- data = _make_data()
- result = await plugin.handle_set_preset(data, preset_name="robot")
- assert result.success is True
- assert plugin._active_preset.preset_id == "robot"
- @pytest.mark.asyncio
- async def test_set_preset_by_label(self, tmp_path):
- """voice_morph_set_preset wechselt Preset per Label."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- plugin.save_config = MagicMock()
- result = await plugin.handle_set_preset(_make_data(), preset_name="Robot Stimme")
- assert result.success is True
- assert plugin._active_preset.preset_id == "robot"
- @pytest.mark.asyncio
- async def test_set_preset_not_found(self, tmp_path):
- """voice_morph_set_preset mit unbekanntem Preset."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- result = await plugin.handle_set_preset(_make_data(), preset_name="nonexistent")
- assert result.success is False
- @pytest.mark.asyncio
- async def test_set_preset_no_name(self, tmp_path):
- """voice_morph_set_preset ohne Preset-Name."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- result = await plugin.handle_set_preset(_make_data(), preset_name="")
- assert result.success is False
- @pytest.mark.asyncio
- async def test_list_presets(self, tmp_path):
- """voice_morph_list_presets listet alle Presets."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- result = await plugin.handle_list_presets(_make_data())
- assert result.success is True
- assert "Test Preset" in result.response_text
- assert "Robot Stimme" in result.response_text
- @pytest.mark.asyncio
- async def test_set_intensity_percent(self, tmp_path):
- """voice_morph_set_intensity mit Prozentwert."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- plugin.save_config = MagicMock()
- result = await plugin.handle_set_intensity(_make_data(), value="80 Prozent")
- assert result.success is True
- assert abs(plugin._intensity - 0.8) < 0.01
- assert "80" in result.response_text
- @pytest.mark.asyncio
- async def test_set_intensity_decimal(self, tmp_path):
- """voice_morph_set_intensity mit Dezimalwert."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- plugin.save_config = MagicMock()
- result = await plugin.handle_set_intensity(_make_data(), value="0.5")
- assert result.success is True
- assert abs(plugin._intensity - 0.5) < 0.01
- @pytest.mark.asyncio
- async def test_set_intensity_clamps(self, tmp_path):
- """voice_morph_set_intensity clampt auf 0-100%."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- plugin.save_config = MagicMock()
- result = await plugin.handle_set_intensity(_make_data(), value="150")
- assert result.success is True
- assert plugin._intensity == 1.0
- @pytest.mark.asyncio
- async def test_set_intensity_invalid(self, tmp_path):
- """voice_morph_set_intensity mit ungueltigem Wert."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- result = await plugin.handle_set_intensity(_make_data(), value="abc")
- assert result.success is False
- @pytest.mark.asyncio
- async def test_set_intensity_empty(self, tmp_path):
- """voice_morph_set_intensity ohne Wert."""
- plugin, _ = _make_plugin(tmp_path)
- await plugin.on_load()
- result = await plugin.handle_set_intensity(_make_data(), value="")
- assert result.success is False
|