"""Test AI Task platform of Ollama integration.""" from pathlib import Path from unittest.mock import patch import ollama import pytest import voluptuous as vol from homeassistant.components import ai_task, media_source from homeassistant.components.ollama import CONF_KEEP_ALIVE from homeassistant.components.ollama.const import KEEP_ALIVE_FOREVER from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector from tests.common import MockConfigEntry @pytest.mark.usefixtures("mock_init_component") async def test_generate_data( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test AI Task data generation.""" entity_id = "ai_task.ollama_ai_task" # Ensure entity is linked to the subentry entity_entry = entity_registry.async_get(entity_id) ai_task_entry = next( iter( entry for entry in mock_config_entry.subentries.values() if entry.subentry_type == "ai_task_data" ) ) assert entity_entry is not None assert entity_entry.config_entry_id == mock_config_entry.entry_id assert entity_entry.config_subentry_id == ai_task_entry.subentry_id # Mock the Ollama chat response as an async iterator async def mock_chat_response(): """Mock streaming response.""" yield { "message": {"role": "assistant", "content": "Generated test data"}, "done": True, "done_reason": "stop", } with patch( "ollama.AsyncClient.chat", return_value=mock_chat_response(), ): result = await ai_task.async_generate_data( hass, task_name="Test Task", entity_id=entity_id, instructions="Generate test data", ) assert result.data == "Generated test data" @pytest.mark.usefixtures("mock_init_component") async def test_run_task_with_streaming( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test AI Task data generation with streaming response.""" entity_id = "ai_task.ollama_ai_task" async def mock_stream(): """Mock streaming response.""" yield {"message": {"role": "assistant", "content": "Stream "}} yield { "message": {"role": "assistant", "content": "response"}, "done": True, "done_reason": "stop", } with patch( "ollama.AsyncClient.chat", return_value=mock_stream(), ): result = await ai_task.async_generate_data( hass, task_name="Test Streaming Task", entity_id=entity_id, instructions="Generate streaming data", ) assert result.data == "Stream response" @pytest.mark.usefixtures("mock_init_component") async def test_run_task_connection_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test AI Task with connection error.""" entity_id = "ai_task.ollama_ai_task" with ( patch( "ollama.AsyncClient.chat", side_effect=Exception("Connection failed"), ), pytest.raises(Exception, match="Connection failed"), ): await ai_task.async_generate_data( hass, task_name="Test Error Task", entity_id=entity_id, instructions="Generate data that will fail", ) @pytest.mark.usefixtures("mock_init_component") async def test_run_task_empty_response( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test AI Task with empty response.""" entity_id = "ai_task.ollama_ai_task" # Mock response with space (minimally non-empty) async def mock_minimal_response(): """Mock minimal streaming response.""" yield { "message": {"role": "assistant", "content": " "}, "done": True, "done_reason": "stop", } with patch( "ollama.AsyncClient.chat", return_value=mock_minimal_response(), ): result = await ai_task.async_generate_data( hass, task_name="Test Minimal Task", entity_id=entity_id, instructions="Generate minimal data", ) assert result.data == " " @pytest.mark.usefixtures("mock_init_component") async def test_generate_structured_data( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test AI Task data generation.""" entity_id = "ai_task.ollama_ai_task" # Mock the Ollama chat response as an async iterator async def mock_chat_response(): """Mock streaming response.""" yield { "message": { "role": "assistant", "content": '{"characters": ["Mario", "Luigi"]}', }, "done": True, "done_reason": "stop", } with patch( "ollama.AsyncClient.chat", return_value=mock_chat_response(), ) as mock_chat: result = await ai_task.async_generate_data( hass, task_name="Test Task", entity_id=entity_id, instructions="Generate test data", structure=vol.Schema( { vol.Required("characters"): selector.selector( { "text": { "multiple": True, } } ) }, ), ) assert result.data == {"characters": ["Mario", "Luigi"]} assert mock_chat.call_count == 1 assert mock_chat.call_args[1]["format"] == { "type": "object", "properties": {"characters": {"items": {"type": "string"}, "type": "array"}}, "required": ["characters"], "additionalProperties": False, } @pytest.mark.usefixtures("mock_init_component") async def test_generate_invalid_structured_data( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test AI Task data generation.""" entity_id = "ai_task.ollama_ai_task" # Mock the Ollama chat response as an async iterator async def mock_chat_response(): """Mock streaming response.""" yield { "message": { "role": "assistant", "content": "INVALID JSON RESPONSE", }, "done": True, "done_reason": "stop", } with ( patch( "ollama.AsyncClient.chat", return_value=mock_chat_response(), ), pytest.raises(HomeAssistantError), ): await ai_task.async_generate_data( hass, task_name="Test Task", entity_id=entity_id, instructions="Generate test data", structure=vol.Schema( { vol.Required("characters"): selector.selector( { "text": { "multiple": True, } } ) }, ), ) @pytest.mark.usefixtures("mock_init_component") async def test_generate_data_with_attachment( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test AI Task data generation with image attachments.""" entity_id = "ai_task.ollama_ai_task" # Mock the Ollama chat response as an async iterator async def mock_chat_response(): """Mock streaming response.""" yield { "message": {"role": "assistant", "content": "Generated test data"}, "done": True, "done_reason": "stop", } with ( patch( "homeassistant.components.media_source.async_resolve_media", side_effect=[ media_source.PlayMedia( url="http://example.com/doorbell_snapshot.jpg", mime_type="image/jpeg", path=Path("doorbell_snapshot.jpg"), ), ], ), patch( "ollama.AsyncClient.chat", return_value=mock_chat_response(), ) as mock_chat, ): result = await ai_task.async_generate_data( hass, task_name="Test Task", entity_id=entity_id, instructions="Generate test data", attachments=[ {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, ], ) assert result.data == "Generated test data" assert mock_chat.call_count == 1 messages = mock_chat.call_args[1]["messages"] assert len(messages) == 2 chat_message = messages[1] assert chat_message.role == "user" assert chat_message.content == "Generate test data" assert chat_message.images == [ ollama.Image(value=Path("doorbell_snapshot.jpg")), ] @pytest.mark.usefixtures("mock_init_component") async def test_generate_data_with_unsupported_file_format( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test AI Task data generation with image attachments.""" entity_id = "ai_task.ollama_ai_task" # Mock the Ollama chat response as an async iterator async def mock_chat_response(): """Mock streaming response.""" yield { "message": {"role": "assistant", "content": "Generated test data"}, "done": True, "done_reason": "stop", } with ( patch( "homeassistant.components.media_source.async_resolve_media", side_effect=[ media_source.PlayMedia( url="http://example.com/doorbell_snapshot.jpg", mime_type="image/jpeg", path=Path("doorbell_snapshot.jpg"), ), media_source.PlayMedia( url="http://example.com/context.txt", mime_type="text/plain", path=Path("context.txt"), ), ], ), patch( "ollama.AsyncClient.chat", return_value=mock_chat_response(), ), pytest.raises( HomeAssistantError, match="Ollama only supports image attachments in user content", ), ): await ai_task.async_generate_data( hass, task_name="Test Task", entity_id=entity_id, instructions="Generate test data", attachments=[ {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, {"media_content_id": "media-source://media/context.txt"}, ], ) @pytest.mark.usefixtures("mock_init_component") async def test_keep_alive_forever_passed_as_int( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test that keep_alive=-1 (forever) is passed as integer -1, not '-1s'.""" entity_id = "ai_task.ollama_ai_task" # Update the AI task subentry to set keep_alive=-1 ai_task_subentry = next( entry for entry in mock_config_entry.subentries.values() if entry.subentry_type == "ai_task_data" ) hass.config_entries.async_update_subentry( mock_config_entry, ai_task_subentry, data={**ai_task_subentry.data, CONF_KEEP_ALIVE: KEEP_ALIVE_FOREVER}, ) await hass.async_block_till_done() async def mock_chat_response(): yield { "message": {"role": "assistant", "content": "ok"}, "done": True, "done_reason": "stop", } with patch( "ollama.AsyncClient.chat", return_value=mock_chat_response(), ) as mock_chat: await ai_task.async_generate_data( hass, task_name="Keep Alive Test", entity_id=entity_id, instructions="Test keep_alive forever", ) assert mock_chat.call_count == 1 # -1 must be passed as an integer, NOT as the string "-1s" assert mock_chat.call_args[1]["keep_alive"] == KEEP_ALIVE_FOREVER assert isinstance(mock_chat.call_args[1]["keep_alive"], int) @pytest.mark.usefixtures("mock_init_component") async def test_keep_alive_duration_passed_as_string( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test that a positive keep_alive value is passed as a duration string.""" entity_id = "ai_task.ollama_ai_task" ai_task_subentry = next( entry for entry in mock_config_entry.subentries.values() if entry.subentry_type == "ai_task_data" ) hass.config_entries.async_update_subentry( mock_config_entry, ai_task_subentry, data={**ai_task_subentry.data, CONF_KEEP_ALIVE: 300}, ) await hass.async_block_till_done() async def mock_chat_response(): yield { "message": {"role": "assistant", "content": "ok"}, "done": True, "done_reason": "stop", } with patch( "ollama.AsyncClient.chat", return_value=mock_chat_response(), ) as mock_chat: await ai_task.async_generate_data( hass, task_name="Keep Alive Duration Test", entity_id=entity_id, instructions="Test keep_alive duration", ) assert mock_chat.call_count == 1 assert mock_chat.call_args[1]["keep_alive"] == "300s" @pytest.mark.usefixtures("mock_init_component") async def test_keep_alive_default_is_forever( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test that the default keep_alive (-1) is passed as integer -1.""" entity_id = "ai_task.ollama_ai_task" async def mock_chat_response(): yield { "message": {"role": "assistant", "content": "ok"}, "done": True, "done_reason": "stop", } with patch( "ollama.AsyncClient.chat", return_value=mock_chat_response(), ) as mock_chat: await ai_task.async_generate_data( hass, task_name="Default Keep Alive Test", entity_id=entity_id, instructions="Test default keep_alive", ) assert mock_chat.call_count == 1 # Default is KEEP_ALIVE_FOREVER (-1), must be an int assert mock_chat.call_args[1]["keep_alive"] == KEEP_ALIVE_FOREVER assert isinstance(mock_chat.call_args[1]["keep_alive"], int)