| """Tests for core.engine engines: MiniCPMTextEngine, LlamaCppTextEngine, EnginePool. |
| |
| Merged from translation_retry_test.py. All tests mock the LLM β no model inference. |
| """ |
|
|
| import pytest |
| from unittest.mock import patch, MagicMock |
|
|
| from core.types import CEFRLevel, TextResult, ValidationError |
| from core.engine import MiniCPMTextEngine, LlamaCppTextEngine, EnginePool |
|
|
|
|
| |
|
|
| def test_minicpm_generate_calls_llm(mock_llm_response_factory): |
| """generate() calls llm.create_chat_completion and wraps in TextResult.""" |
| mock_llm = MagicMock() |
| mock_llm.create_chat_completion.return_value = mock_llm_response_factory( |
| "1. Hello.\n2. World." |
| ) |
|
|
| with patch.object(MiniCPMTextEngine, "_load_model"): |
| engine = MiniCPMTextEngine.__new__(MiniCPMTextEngine) |
| engine._llm = mock_llm |
| engine._loaded = True |
|
|
| result = engine.generate( |
| texts=[], |
| scenario="test", |
| cefr_level=CEFRLevel.A1, |
| batch_size=2, |
| ) |
|
|
| assert isinstance(result, TextResult) |
| assert len(result.generated_texts) == 2 |
| assert result.generated_texts[0] == "Hello." |
| mock_llm.create_chat_completion.assert_called_once() |
|
|
|
|
| def test_minicpm_generate_propagates_validation_error(): |
| """ValidationError from text_gen propagate through generate().""" |
| from core.text_gen import ValidationError as TextGenValidationError |
|
|
| mock_llm = MagicMock() |
| |
| mock_llm.create_chat_completion.return_value = {"choices": [{"message": {"content": "no numbers here"}}]} |
|
|
| with patch.object(MiniCPMTextEngine, "_load_model"): |
| engine = MiniCPMTextEngine.__new__(MiniCPMTextEngine) |
| engine._llm = mock_llm |
| engine._loaded = True |
|
|
| |
| with pytest.raises((ValidationError, TextGenValidationError)): |
| engine.generate( |
| texts=[], |
| scenario="test", |
| cefr_level=CEFRLevel.A1, |
| batch_size=2, |
| ) |
|
|
|
|
| |
|
|
| def test_is_valid_translation_valid(): |
| """Valid: non-empty single line, no English words.""" |
| with patch.object(LlamaCppTextEngine, "_load_model"): |
| engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine) |
| engine._loaded = True |
|
|
| assert engine._is_valid_translation("Sveiki.") is True |
| assert engine._is_valid_translation("LabrΔ«t!") is True |
| assert engine._is_valid_translation("Paldies, ka jautΔji.") is True |
|
|
|
|
| def test_is_valid_translation_invalid_empty(): |
| """Invalid: empty string or whitespace-only.""" |
| with patch.object(LlamaCppTextEngine, "_load_model"): |
| engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine) |
| engine._loaded = True |
|
|
| assert engine._is_valid_translation("") is False |
| assert engine._is_valid_translation(" ") is False |
|
|
|
|
| def test_is_valid_translation_invalid_english_words(): |
| """Invalid: contains English words (model echoed back).""" |
| with patch.object(LlamaCppTextEngine, "_load_model"): |
| engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine) |
| engine._loaded = True |
|
|
| assert engine._is_valid_translation("This is the translation") is False |
| assert engine._is_valid_translation("Translate this sentence") is False |
|
|
|
|
| def test_is_valid_translation_invalid_multiline(): |
| """Invalid: multiline output.""" |
| with patch.object(LlamaCppTextEngine, "_load_model"): |
| engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine) |
| engine._loaded = True |
|
|
| assert engine._is_valid_translation("Line1\nLine2") is False |
|
|
|
|
| |
|
|
| def test_translate_single_success(): |
| """Valid translation returned on first attempt.""" |
| mock_llm = MagicMock() |
| mock_llm.create_chat_completion.return_value = {"choices": [{"message": {"content": "Sveiki."}}]} |
|
|
| with patch.object(LlamaCppTextEngine, "_load_model"), \ |
| patch.object(LlamaCppTextEngine, "unload"): |
| engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine) |
| engine._llm = mock_llm |
| engine._loaded = True |
| engine.target_language = "Latvian" |
|
|
| result = engine._translate_single("Hello.", CEFRLevel.A1) |
|
|
| assert result == "Sveiki." |
| assert mock_llm.create_chat_completion.call_count == 1 |
|
|
|
|
| def test_translate_single_retry_on_invalid(): |
| """Retry when first output invalid (contains English word), second succeeds.""" |
| mock_llm = MagicMock() |
| mock_llm.create_chat_completion.side_effect = [ |
| {"choices": [{"message": {"content": "This is the English text"}}]}, |
| {"choices": [{"message": {"content": "Paldies."}}]}, |
| ] |
|
|
| with patch.object(LlamaCppTextEngine, "_load_model"), \ |
| patch.object(LlamaCppTextEngine, "unload"): |
| engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine) |
| engine._llm = mock_llm |
| engine._loaded = True |
| engine.target_language = "Latvian" |
|
|
| result = engine._translate_single("Thank you.", CEFRLevel.A1) |
|
|
| assert result == "Paldies." |
| assert mock_llm.create_chat_completion.call_count == 2 |
|
|
|
|
| def test_translate_single_exhausted_retries_fallback(): |
| """Exhausted retries β fallback to original English text.""" |
| mock_llm = MagicMock() |
| |
| mock_llm.create_chat_completion.return_value = {"choices": [{"message": {"content": ""}}]} |
|
|
| with patch.object(LlamaCppTextEngine, "_load_model"), \ |
| patch.object(LlamaCppTextEngine, "unload"): |
| engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine) |
| engine._llm = mock_llm |
| engine._loaded = True |
| engine.target_language = "Latvian" |
|
|
| result = engine._translate_single("Hello.", CEFRLevel.A1) |
|
|
| assert result == "Hello." |
| assert mock_llm.create_chat_completion.call_count == 3 |
|
|
|
|
| def test_translate_single_multiline_rejected(): |
| """Multiline output rejected, triggers retry.""" |
| mock_llm = MagicMock() |
| mock_llm.create_chat_completion.side_effect = [ |
| {"choices": [{"message": {"content": "Line1\nLine2"}}]}, |
| {"choices": [{"message": {"content": "Paldies."}}]}, |
| ] |
|
|
| with patch.object(LlamaCppTextEngine, "_load_model"), \ |
| patch.object(LlamaCppTextEngine, "unload"): |
| engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine) |
| engine._llm = mock_llm |
| engine._loaded = True |
| engine.target_language = "Latvian" |
|
|
| result = engine._translate_single("Hello.", CEFRLevel.A1) |
|
|
| assert result == "Paldies." |
| assert mock_llm.create_chat_completion.call_count == 2 |
|
|
|
|
| |
|
|
| def test_generate_calls_per_sentence(): |
| """generate() calls _translate_single for each input text.""" |
| mock_llm = MagicMock() |
| responses = [ |
| {"choices": [{"message": {"content": "Sveiki."}}]}, |
| {"choices": [{"message": {"content": "KΔ tu esi?"}}]}, |
| {"choices": [{"message": {"content": "Paldies."}}]}, |
| ] |
| mock_llm.create_chat_completion.side_effect = responses |
|
|
| with patch.object(LlamaCppTextEngine, "_load_model"), \ |
| patch.object(LlamaCppTextEngine, "unload"): |
| engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine) |
| engine._llm = mock_llm |
| engine._loaded = True |
| engine.target_language = "Latvian" |
|
|
| result = engine.generate( |
| texts=["Hello.", "How are you?", "Thank you."], |
| scenario="greetings", |
| cefr_level=CEFRLevel.A1, |
| batch_size=3, |
| ) |
|
|
| assert len(result.generated_texts) == 3 |
| assert result.generated_texts[0] == "Sveiki." |
| assert result.generated_texts[1] == "KΔ tu esi?" |
| assert result.generated_texts[2] == "Paldies." |
| assert mock_llm.create_chat_completion.call_count == 3 |
|
|
|
|
| |
|
|
| def test_engine_pool_get_creates_singleton(): |
| """First get() creates a new EnginePool instance.""" |
| from core.types import EngineConfig |
|
|
| config = MagicMock(spec=EngineConfig) |
| config.batch_size = 3 |
| config.target_language = "Latvian" |
| config.device = "cpu" |
| pool = EnginePool.get(config) |
| assert isinstance(pool, EnginePool) |
| EnginePool.reset() |
|
|
|
|
| def test_engine_pool_get_returns_same_instance(): |
| """Second get() returns the same instance.""" |
| from core.types import EngineConfig |
|
|
| config = MagicMock(spec=EngineConfig) |
| config.batch_size = 3 |
| config.target_language = "Latvian" |
| config.device = "cpu" |
| pool1 = EnginePool.get(config) |
| pool2 = EnginePool.get(config) |
| assert pool1 is pool2 |
| EnginePool.reset() |
|
|
|
|
| def test_engine_pool_reset_clears_singleton(): |
| """reset() clears singleton and unloads engines.""" |
| from core.types import EngineConfig |
|
|
| config = MagicMock(spec=EngineConfig) |
| config.batch_size = 3 |
| config.target_language = "Latvian" |
| config.device = "cpu" |
| pool1 = EnginePool.get(config) |
|
|
| |
| pool2 = EnginePool.get(config) |
| assert pool1 is pool2 |
|
|
| EnginePool.reset() |
|
|
| |
| pool3 = EnginePool.get(config) |
| assert pool3 is not pool1 |
|
|