Takosaga commited on
Commit
1eb3704
ยท
1 Parent(s): a1b6efd

feat: test use pytest and test all modules

Browse files
AGENTS.md CHANGED
@@ -232,33 +232,44 @@ Phase 2 translation orchestration layer. Provides `generate_phase2()` as a gener
232
 
233
  ## Testing Expectations
234
 
235
- ### Smoke Tests
236
 
237
- Run `tests/smoke_test.py` before committing. It performs a quick sanity check: imports all modules, validates dataclasses, and checks that the Gradio app can be constructed without errors.
238
 
239
  ```bash
240
- python tests/smoke_test.py
241
  ```
242
 
243
- Expected output: clean exit (no traceback). If it fails, something is broken at the module level.
244
-
245
- ### Mock Data
246
-
247
- The frontend can render cards from mock data (no model inference needed). When testing UI changes:
248
- - Use `frontend/ui/cards.py:render_card_html()` directly with a dict like `{"text": "Hello", "translation": "Sveiki"}`
249
- - The card renderer handles missing fields gracefully โ€” `translation` defaults to empty string, `audio_path`/`image_path` are ignored in HTML rendering.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
 
251
- ### Inline Tests for Engine Retry Logic
252
-
253
- For engine classes with retry loops, add inline tests that mock the LLM and verify count validation. See `tests/translation_retry_test.py` as an example โ€” it tests `LlamaCppTextEngine.generate()` retry logic (exact count, short output, exhausted retries, empty output) without requiring a running model.
254
-
255
- ### Inline Tests
256
 
257
- For new modules with non-trivial logic, add a test script in `tests/` guarded by `if __name__ == "__main__":`. See `tests/count_enforcement_test.py` as an example โ€” it tests `TextResult.validate_and_parse()` (thinking-tag stripping, line-count enforcement) and retry-prompt logic without requiring model inference.
258
 
259
- ### No Unit Test Framework Required (Yet)
260
 
261
- The project currently uses smoke tests and inline tests. If you add a new module with non-trivial logic (>30 lines of business logic), consider adding inline assertions or a simple test function at the bottom of the file guarded by `if __name__ == "__main__":`.
262
 
263
  ## Adding New Features
264
 
@@ -269,7 +280,7 @@ Use this checklist when extending EuropaLex:
269
  3. **Implement core logic** โ€” In `core/` or the appropriate module. Follow the protocol pattern from `engine.py`.
270
  4. **Wire up the UI** โ€” Add widgets in `frontend/ui/widgets.py`, renderers in `frontend/ui/cards.py`. Update `app.py` click handlers last.
271
  5. **Update CSS if needed** โ€” New visual elements go in `frontend/css/custom.css`. Keep inline styles only for card-level dynamic properties (rotation, conditional display).
272
- 6. **Test with smoke test** โ€” Run `python tests/smoke_test.py`.
273
  7. **Commit** โ€” One logical change per commit. Message format: `type: brief description` (e.g., `feat: add Japanese language support`, `fix: card rotation overflow`).
274
 
275
  ## Git Workflow
@@ -292,7 +303,7 @@ Use [Conventional Commits](https://www.conventionalcommits.org/) prefix:
292
 
293
  ### Before Merging
294
 
295
- 1. Run `python tests/smoke_test.py` โ€” must pass
296
  2. Verify the Gradio app starts: `python app.py` โ€” must launch without errors on port 7860
297
  3. Check that all new code follows the conventions in this document
298
 
 
232
 
233
  ## Testing Expectations
234
 
235
+ ### Pytest Test Suite
236
 
237
+ All tests use pytest. Run the full suite before committing:
238
 
239
  ```bash
240
+ uv run pytest tests/ -v
241
  ```
242
 
243
+ **Test file naming convention:** `*_test.py` โ€” one file per source module, flat structure in `tests/`:
244
+
245
+ | Test File | Covers |
246
+ |---|---|
247
+ | `conftest.py` | Shared fixtures (mock data, paths, temp dirs) |
248
+ | `smoke_test.py` | Import validation + Pydantic model construction |
249
+ | `cards_test.py` | Card HTML rendering functions |
250
+ | `widgets_test.py` | Widget creation and UI state helpers |
251
+ | `app_test.py` | App async generators and helper functions |
252
+ | `audio_gen_test.py` | TTSEngine (TTS audio generation) |
253
+ | `image_gen_test.py` | ImageGenEngine (image generation) |
254
+ | `engine_test.py` | MiniCPMTextEngine, LlamaCppTextEngine, EnginePool |
255
+ | `pipeline_test.py` | Phase 2 orchestration |
256
+ | `text_gen_test.py` | Sentence extraction + text generation |
257
+
258
+ ### Writing Tests
259
+
260
+ - Use fixtures from `tests/conftest.py` for mock data and paths.
261
+ - Mock all GPU/model code via `unittest.mock.patch` โ€” no real inference needed.
262
+ - Use assertions (`assert`, `pytest.raises`) instead of print statements.
263
+ - Generator functions consumed via `list(handler(...))` to capture all yields.
264
+ - Real `.wav` and `.png` files from `tests/test_outputs/` serve as file-existence fixtures.
265
 
266
+ ### Smoke Tests
 
 
 
 
267
 
268
+ Run `uv run pytest tests/smoke_test.py -v` for a quick sanity check: imports all modules, validates Pydantic models, and checks that the Gradio app can be constructed without errors.
269
 
270
+ ### Inline Tests (Legacy)
271
 
272
+ The old `if __name__ == "__main__":` inline test pattern is deprecated. All tests should be in `*_test.py` files under `tests/`. Do not add new inline tests.
273
 
274
  ## Adding New Features
275
 
 
280
  3. **Implement core logic** โ€” In `core/` or the appropriate module. Follow the protocol pattern from `engine.py`.
281
  4. **Wire up the UI** โ€” Add widgets in `frontend/ui/widgets.py`, renderers in `frontend/ui/cards.py`. Update `app.py` click handlers last.
282
  5. **Update CSS if needed** โ€” New visual elements go in `frontend/css/custom.css`. Keep inline styles only for card-level dynamic properties (rotation, conditional display).
283
+ 6. **Test** โ€” Run `uv run pytest tests/smoke_test.py -v` for a quick sanity check.
284
  7. **Commit** โ€” One logical change per commit. Message format: `type: brief description` (e.g., `feat: add Japanese language support`, `fix: card rotation overflow`).
285
 
286
  ## Git Workflow
 
303
 
304
  ### Before Merging
305
 
306
+ 1. Run `uv run pytest tests/ -v` โ€” must pass
307
  2. Verify the Gradio app starts: `python app.py` โ€” must launch without errors on port 7860
308
  3. Check that all new code follows the conventions in this document
309
 
README.md CHANGED
@@ -31,12 +31,29 @@ uv run app.py
31
 
32
  > **Dependencies:** This project requires PyTorch, diffusers, omnivoice, pydantic, and soundfile in addition to Gradio. These are installed automatically by `uv sync`.
33
 
34
- ### Running Smoke Tests
35
 
36
- All smoke tests pass. Before committing, verify all modules load correctly:
37
 
38
  ```bash
39
- uv run python tests/smoke_test.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  ```
41
 
42
  This checks imports for core types, engine classes, frontend UI, and the app module. The Gradio app must construct without errors โ€” all widgets are created inside a `gr.Blocks()` context and the context variable is returned (not a fresh empty `Blocks` instance). Generator event handlers use `yield (val1, val2)` not `yield from` to match output component counts.
 
31
 
32
  > **Dependencies:** This project requires PyTorch, diffusers, omnivoice, pydantic, and soundfile in addition to Gradio. These are installed automatically by `uv sync`.
33
 
34
+ ### Running Tests
35
 
36
+ All tests use pytest. Run the full suite:
37
 
38
  ```bash
39
+ # Run all tests
40
+ uv run pytest tests/ -v
41
+
42
+ # Run specific test file
43
+ uv run pytest tests/cards_test.py -v
44
+
45
+ # Run with coverage
46
+ uv run pytest tests/ -v --cov=core --cov=frontend --cov=app.py
47
+ ```
48
+
49
+ The test suite mocks all GPU/model code โ€” no model weights or GPU required to run tests.
50
+
51
+ ### Quick Smoke Check
52
+
53
+ For a quick sanity check before committing:
54
+
55
+ ```bash
56
+ uv run pytest tests/smoke_test.py -v
57
  ```
58
 
59
  This checks imports for core types, engine classes, frontend UI, and the app module. The Gradio app must construct without errors โ€” all widgets are created inside a `gr.Blocks()` context and the context variable is returned (not a fresh empty `Blocks` instance). Generator event handlers use `yield (val1, val2)` not `yield from` to match output component counts.
docs/superpowers/plans/2026-06-13-pytest-migration.md ADDED
@@ -0,0 +1,1892 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pytest Migration Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Migrate all EuropaLex tests from `if __name__ == "__main__":` + `print()` inline style to proper pytest test files with fixtures, mocking, and assertions.
6
+
7
+ **Architecture:** Flat test structure in `tests/`, one file per source module. All GPU/model code mocked via `unittest.mock`. Real `.wav` and `.png` files from `tests/test_outputs/` serve as file-existence fixtures.
8
+
9
+ **Tech Stack:** pytest 9+, unittest.mock, Pydantic, Gradio (mocked)
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ ```
16
+ tests/
17
+ โ”œโ”€โ”€ conftest.py # Shared fixtures (NEW)
18
+ โ”œโ”€โ”€ smoke_test.py # Pytest rewrite: imports + types validation
19
+ โ”œโ”€โ”€ cards_test.py # Card HTML rendering (NEW)
20
+ โ”œโ”€โ”€ widgets_test.py # Widget creation + UI state helpers (NEW)
21
+ โ”œโ”€โ”€ app_test.py # App helpers + async generators (NEW)
22
+ โ”œโ”€โ”€ audio_gen_test.py # TTSEngine (NEW)
23
+ โ”œโ”€โ”€ image_gen_test.py # ImageGenEngine (NEW)
24
+ โ”œโ”€โ”€ engine_test.py # MiniCPMTextEngine, LlamaCppTextEngine, EnginePool
25
+ โ”œโ”€โ”€ pipeline_test.py # Phase 2 orchestration (NEW)
26
+ โ””โ”€โ”€ text_gen_test.py # Merged: extract_sentences + generate_sentences
27
+ ```
28
+
29
+ **Old files to remove after migration:** `count_enforcement_test.py`, `extract_sentences_test.py`, `translation_retry_test.py`, `progression_test.py`
30
+
31
+ ## Source Module Reference
32
+
33
+ | File | Functions/Classes Tested |
34
+ |---|---|
35
+ | `core/types.py` | CEFRLevel, CardData, TextResult, AudioResult, ImageResult, EngineConfig, ValidationError |
36
+ | `core/text_gen.py` | extract_sentences(), generate_sentences() |
37
+ | `core/engine.py` | MiniCPMTextEngine.generate(), LlamaCppTextEngine._translate_single(), _is_valid_translation(), generate(), EnginePool.get()/reset() |
38
+ | `core/audio_gen.py` | TTSEngine.synthesize(), unload() |
39
+ | `core/image_gen.py` | ImageGenEngine.generate(), unload() |
40
+ | `core/pipeline.py` | generate_phase2() generator |
41
+ | `frontend/ui/cards.py` | render_card_html(), generate_cards_html(), generate_progress_html() |
42
+ | `frontend/ui/widgets.py` | create_toggle(), create_voice_dropdown(), _VOICE_MAP, _enable_phase2(), _reset_to_idle(), _enable_language_dropdown_on_audio() |
43
+ | `app.py` | transform_mock_cards(), _progress_pct(), generate_text_async(), generate_media_async() |
44
+
45
+ ---
46
+
47
+ ### Task 1: conftest.py โ€” Shared Fixtures
48
+
49
+ **Files:**
50
+ - Create: `tests/conftest.py`
51
+
52
+ ```python
53
+ """Shared pytest fixtures for EuropaLex test suite."""
54
+
55
+ from pathlib import Path
56
+
57
+ import pytest
58
+
59
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
60
+
61
+
62
+ @pytest.fixture
63
+ def mock_english_texts():
64
+ """Phase 1 English sentences."""
65
+ return [
66
+ "I love eating fresh fruits.",
67
+ "She enjoys cooking pasta.",
68
+ "The chef prepared a delicious meal.",
69
+ ]
70
+
71
+
72
+ @pytest.fixture
73
+ def mock_spanish_translations():
74
+ """Phase 2 Spanish translations."""
75
+ return [
76
+ "Me encanta comer frutas frescas.",
77
+ "Le encanta cocinar pasta.",
78
+ "El chef preparรณ una comida deliciosa.",
79
+ ]
80
+
81
+
82
+ @pytest.fixture
83
+ def mock_audio_paths():
84
+ """Real .wav paths from tests/test_outputs/audio/ for file-existence tests."""
85
+ audio_dir = PROJECT_ROOT / "tests" / "test_outputs" / "audio"
86
+ return [str(audio_dir / f"audio_{i}.wav") for i in range(3)]
87
+
88
+
89
+ @pytest.fixture
90
+ def mock_image_paths():
91
+ """Real .png paths from tests/test_outputs/images/ for file-existence tests."""
92
+ image_dir = PROJECT_ROOT / "tests" / "test_outputs" / "images"
93
+ return [str(image_dir / f"image_{i}.png") for i in range(3)]
94
+
95
+
96
+ @pytest.fixture
97
+ def temp_output_dir(tmp_path):
98
+ """Temporary directory for TTS/image generation tests, auto-cleaned."""
99
+ output = tmp_path / "output"
100
+ output.mkdir(parents=True)
101
+ return output
102
+
103
+
104
+ @pytest.fixture
105
+ def mock_llm_response_factory():
106
+ """Factory to build LLM response dicts: {"choices": [{"message": {"content": "..."}}]}."""
107
+
108
+ def _factory(content: str):
109
+ return {"choices": [{"message": {"content": content}}]}
110
+
111
+ return _factory
112
+ ```
113
+
114
+ ---
115
+
116
+ ### Task 2: smoke_test.py โ€” Pytest Rewrite (Imports + Types)
117
+
118
+ **Files:**
119
+ - Create: `tests/smoke_test.py`
120
+
121
+ ```python
122
+ """Pytest rewrite of EuropaLex smoke test.
123
+
124
+ Validates: all modules import, Pydantic model construction,
125
+ TextResult.validate_and_parse() gate behavior.
126
+ """
127
+
128
+ import pytest
129
+
130
+
131
+ def test_all_modules_import():
132
+ """All project modules can be imported without error."""
133
+ import core.types # noqa: F401
134
+ import core.text_gen # noqa: F401
135
+ import core.engine # noqa: F401
136
+ import core.audio_gen # noqa: F401
137
+ import core.image_gen # noqa: F401
138
+ import frontend.ui.cards # noqa: F401
139
+ import frontend.ui.widgets # noqa: F401
140
+
141
+
142
+ def test_carddata_construction():
143
+ """CardData Pydantic model constructs with all fields."""
144
+ from core.types import CardData
145
+
146
+ card = CardData(text="Hello", translation="Sveiki")
147
+ assert card.text == "Hello"
148
+ assert card.translation == "Sveiki"
149
+ assert card.audio_path is None
150
+ assert card.image_path is None
151
+
152
+
153
+ def test_textresult_construction():
154
+ """TextResult constructs with generated_texts list."""
155
+ from core.types import TextResult
156
+
157
+ result = TextResult(generated_texts=["A.", "B."])
158
+ assert len(result.generated_texts) == 2
159
+
160
+
161
+ def test_audioreresult_construction():
162
+ """AudioResult defaults to empty list."""
163
+ from core.types import AudioResult
164
+
165
+ result = AudioResult()
166
+ assert result.audio_paths == []
167
+
168
+
169
+ def test_imageresult_construction():
170
+ """ImageResult defaults to empty list."""
171
+ from core.types import ImageResult
172
+
173
+ result = ImageResult()
174
+ assert result.image_paths == []
175
+
176
+
177
+ def test_engineconfig_from_settings():
178
+ """EngineConfig loads from settings.yaml (uses default paths, no model check)."""
179
+ from core.types import EngineConfig
180
+
181
+ config = EngineConfig.from_settings_yaml()
182
+ assert config.batch_size > 0
183
+ assert config.device in ("cuda", "mps", "cpu")
184
+
185
+
186
+ def test_cefrlevel_enum():
187
+ """CEFRLevel enum has all expected values and label/description methods."""
188
+ from core.types import CEFRLevel
189
+
190
+ levels = [CEFRLevel.A1, CEFRLevel.A2, CEFRLevel.B1, CEFRLevel.B2, CEFRLevel.C1, CEFRLevel.C2]
191
+ for level in levels:
192
+ assert isinstance(level.label(), str)
193
+ assert len(level.label()) > 0
194
+ assert isinstance(level.description(), str)
195
+ assert len(level.description()) > 0
196
+
197
+
198
+ def test_validationerror_structure():
199
+ """ValidationError carries raw_output attribute."""
200
+ from core.types import ValidationError
201
+
202
+ err = ValidationError("test message", raw_output="raw llm output")
203
+ assert err.raw_output == "raw llm output"
204
+ assert str(err) == "test message"
205
+
206
+
207
+ def test_textresult_validate_and_parse_strips_thinking_tags():
208
+ """validate_and_parse strips <thinking> tags before splitting lines."""
209
+ from core.types import TextResult
210
+
211
+ raw = "<thinking>reasoning</thinking>\nHello.\nWorld."
212
+ result = TextResult.validate_and_parse(raw, expected_count=2)
213
+ assert result.generated_texts == ["Hello.", "World."]
214
+
215
+
216
+ def test_textresult_validate_and_parse_enforces_count():
217
+ """validate_and_parse raises ValidationError when count mismatches."""
218
+ from core.types import TextResult, ValidationError
219
+
220
+ raw = "Line one.\nLine two."
221
+ with pytest.raises(ValidationError) as exc_info:
222
+ TextResult.validate_and_parse(raw, expected_count=5)
223
+ assert "Expected 5 sentences but got 2" in str(exc_info.value)
224
+ assert exc_info.value.raw_output == raw
225
+
226
+
227
+ def test_textresult_validate_and_parse_empty_raises():
228
+ """validate_and_parse raises ValidationError on empty output after tag stripping."""
229
+ from core.types import TextResult, ValidationError
230
+
231
+ raw = "<thinking>only reasoning</thinking>"
232
+ with pytest.raises(ValidationError):
233
+ TextResult.validate_and_parse(raw, expected_count=1)
234
+
235
+
236
+ def test_textresult_validate_and_parse_no_expected_count():
237
+ """validate_and_parse returns all lines when expected_count is None."""
238
+ from core.types import TextResult
239
+
240
+ raw = "A.\nB.\nC."
241
+ result = TextResult.validate_and_parse(raw, expected_count=None)
242
+ assert len(result.generated_texts) == 3
243
+ ```
244
+
245
+ ---
246
+
247
+ ### Task 3: cards_test.py โ€” Card HTML Rendering
248
+
249
+ **Files:**
250
+ - Create: `tests/cards_test.py`
251
+
252
+ ```python
253
+ """Tests for frontend.ui.cards card rendering functions."""
254
+
255
+ from pathlib import Path
256
+ import pytest
257
+
258
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
259
+ from frontend.ui.cards import render_card_html, generate_cards_html, generate_progress_html
260
+
261
+
262
+ @pytest.fixture
263
+ def mock_audio_paths():
264
+ audio_dir = PROJECT_ROOT / "tests" / "test_outputs" / "audio"
265
+ return [str(audio_dir / f"audio_{i}.wav") for i in range(3)]
266
+
267
+
268
+ @pytest.fixture
269
+ def mock_image_paths():
270
+ image_dir = PROJECT_ROOT / "tests" / "test_outputs" / "images"
271
+ return [str(image_dir / f"image_{i}.png") for i in range(3)]
272
+
273
+
274
+ # โ”€โ”€ render_card_html โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
275
+
276
+ def test_render_card_html_placeholder_mode():
277
+ """Placeholder mode: English on front, dashed placeholder back."""
278
+ card = {
279
+ "text": "Hello world.",
280
+ "translation": "",
281
+ "cefr_level": "A1",
282
+ }
283
+ html = render_card_html(card, include_image=False, include_audio=False, rotation=0, placeholder_back=True)
284
+ assert "Hello world." in html
285
+ assert "card-placeholder-back" in html
286
+
287
+
288
+ def test_render_card_html_normal_mode():
289
+ """Normal mode: translation on front, English on back."""
290
+ card = {
291
+ "text": "Hello world.",
292
+ "translation": "Sveiki pasaule.",
293
+ "cefr_level": "A1",
294
+ }
295
+ html = render_card_html(card, include_image=False, include_audio=False, rotation=0, placeholder_back=False)
296
+ assert "Sveiki pasaule." in html
297
+ assert "Hello world." in html
298
+
299
+
300
+ def test_render_card_html_with_existing_image(mock_image_paths):
301
+ """Existing image file โ†’ <img> tag in HTML."""
302
+ card = {
303
+ "text": "A cat.",
304
+ "translation": "Kaฤทis.",
305
+ "image_path": mock_image_paths[0],
306
+ }
307
+ html = render_card_html(card, include_image=True, include_audio=False, rotation=0, placeholder_back=False)
308
+ assert "<img" in html
309
+
310
+
311
+ def test_render_card_html_with_missing_image():
312
+ """Missing image file โ†’ placeholder emoji."""
313
+ card = {
314
+ "text": "A cat.",
315
+ "translation": "Kaฤทis.",
316
+ "image_path": "/nonexistent/path.png",
317
+ }
318
+ html = render_card_html(card, include_image=True, include_audio=False, rotation=0, placeholder_back=False)
319
+ assert "<img" not in html
320
+
321
+
322
+ def test_render_card_html_with_existing_audio(mock_audio_paths):
323
+ """Existing audio file โ†’ <audio> element in HTML."""
324
+ card = {
325
+ "text": "Hello.",
326
+ "translation": "Sveiki.",
327
+ "audio_path": mock_audio_paths[0],
328
+ }
329
+ html = render_card_html(card, include_image=False, include_audio=True, rotation=0, placeholder_back=False)
330
+ assert "<audio" in html
331
+
332
+
333
+ def test_render_card_html_with_missing_audio():
334
+ """Missing audio file โ†’ play button."""
335
+ card = {
336
+ "text": "Hello.",
337
+ "translation": "Sveiki.",
338
+ "audio_path": "/nonexistent/path.wav",
339
+ }
340
+ html = render_card_html(card, include_image=False, include_audio=True, rotation=0, placeholder_back=False)
341
+ assert "<audio" not in html
342
+ assert "media-btn" in html
343
+
344
+
345
+ def test_render_card_html_rotation_applied():
346
+ """Rotation parameter applied to transform style."""
347
+ card = {"text": "Hello", "translation": ""}
348
+ html = render_card_html(card, include_image=False, include_audio=False, rotation=3.5, placeholder_back=False)
349
+ assert "rotate(3.5deg)" in html
350
+
351
+
352
+ # โ”€โ”€ generate_cards_html โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
353
+
354
+ def test_generate_cards_html_empty_list():
355
+ """Empty cards list โ†’ 'No cards' message."""
356
+ html = generate_cards_html([], include_image=False, include_audio=False)
357
+ assert "No cards" in html or "<div" in html
358
+
359
+
360
+ def test_generate_cards_html_single_card():
361
+ """Single card renders without rotation variation issues."""
362
+ cards = [{"text": "Hello", "translation": "Sveiki"}]
363
+ html = generate_cards_html(cards, include_image=False, include_audio=False)
364
+ assert "Hello" in html
365
+ assert "Sveiki" in html
366
+
367
+
368
+ def test_generate_cards_html_multi_card_rotation_distribution():
369
+ """Multiple cards get varied rotation angles for spread-on-desk effect."""
370
+ cards = [{"text": f"Sentence {i}", "translation": f"Tulkojums {i}"} for i in range(5)]
371
+ html = generate_cards_html(cards, include_image=False, include_audio=False)
372
+ # All sentences present
373
+ for i in range(5):
374
+ assert f"Sentence {i}" in html
375
+
376
+
377
+ def test_generate_cards_html_image_only():
378
+ """include_image=True, include_audio=False โ†’ images only."""
379
+ cards = [{"text": "A.", "translation": "B.", "image_path": PROJECT_ROOT / "tests" / "test_outputs" / "images" / "image_0.png"}]
380
+ html = generate_cards_html(cards, include_image=True, include_audio=False)
381
+ assert "<img" in html
382
+
383
+
384
+ def test_generate_cards_html_audio_only():
385
+ """include_image=False, include_audio=True โ†’ audio only."""
386
+ cards = [{"text": "A.", "translation": "B.", "audio_path": PROJECT_ROOT / "tests" / "test_outputs" / "audio" / "audio_0.wav"}]
387
+ html = generate_cards_html(cards, include_image=False, include_audio=True)
388
+ assert "<audio" in html
389
+
390
+
391
+ def test_generate_cards_html_both_media():
392
+ """Both image and audio toggles โ†’ both media boxes present."""
393
+ cards = [{
394
+ "text": "A.", "translation": "B.",
395
+ "image_path": PROJECT_ROOT / "tests" / "test_outputs" / "images" / "image_0.png",
396
+ "audio_path": PROJECT_ROOT / "tests" / "test_outputs" / "audio" / "audio_0.wav",
397
+ }]
398
+ html = generate_cards_html(cards, include_image=True, include_audio=True)
399
+ assert "<img" in html
400
+ assert "<audio" in html
401
+
402
+
403
+ def test_generate_cards_html_neither_media():
404
+ """Both toggles off โ†’ no media boxes."""
405
+ cards = [{"text": "A.", "translation": "B."}]
406
+ html = generate_cards_html(cards, include_image=False, include_audio=False)
407
+ assert "<img" not in html
408
+ assert "<audio" not in html
409
+
410
+
411
+ def test_generate_cards_html_placeholder_back_mode():
412
+ """placeholder_back=True โ†’ dashed placeholder instead of translation."""
413
+ cards = [{"text": "Hello", "translation": ""}]
414
+ html = generate_cards_html(cards, include_image=False, include_audio=False, placeholder_back=True)
415
+ assert "card-placeholder-back" in html
416
+
417
+
418
+ # โ”€โ”€ generate_progress_html โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
419
+
420
+ def test_generate_progress_html_zero_percent():
421
+ """0% โ†’ empty string (hidden by Gradio)."""
422
+ result = generate_progress_html(0, "")
423
+ assert result == ""
424
+
425
+
426
+ def test_generate_progress_html_mid_progress_color():
427
+ """10-59% โ†’ brown bar."""
428
+ html = generate_progress_html(50, "Working...")
429
+ assert "width: 50%" in html
430
+
431
+
432
+ def test_generate_progress_html_60_percent_dark_brown():
433
+ """60%+ โ†’ dark brown bar."""
434
+ html = generate_progress_html(60, "Almost done...")
435
+ assert "#8b7355" in html or "#6b5e4a" in html
436
+
437
+
438
+ def test_generate_progress_html_100_percent_complete():
439
+ """100% โ†’ dark brown bar, green 'complete' text."""
440
+ html = generate_progress_html(100, "Complete!")
441
+ assert "width: 100%" in html
442
+ assert "#4CAF50" in html or "green" in html.lower()
443
+ ```
444
+
445
+ ---
446
+
447
+ ### Task 4: widgets_test.py โ€” Widget Creation + UI State Helpers
448
+
449
+ **Files:**
450
+ - Create: `tests/widgets_test.py`
451
+
452
+ ```python
453
+ """Tests for frontend.ui.widgets widget creation and UI state helpers."""
454
+
455
+ import pytest
456
+ from unittest.mock import patch, MagicMock
457
+
458
+ # Patch gradio at module level before importing widgets
459
+ mock_gr = MagicMock()
460
+ mock_gr.Blocks = MagicMock()
461
+ mock_gr.Checkbox = MagicMock()
462
+ mock_gr.Button = MagicMock()
463
+ mock_gr.Dropdown = MagicMock()
464
+
465
+ with patch.dict('sys.modules', {'gradio': mock_gr}):
466
+ from frontend.ui.widgets import (
467
+ create_toggle,
468
+ create_voice_dropdown,
469
+ _VOICE_MAP,
470
+ _enable_phase2,
471
+ _reset_to_idle,
472
+ _enable_language_dropdown_on_audio,
473
+ )
474
+
475
+
476
+ def test_create_toggle_label_with_emoji():
477
+ """Toggle label includes the provided emoji prefix."""
478
+ checkbox = create_toggle("๐Ÿ–ผ๏ธ Images", value=True, elem_id="toggle-images")
479
+ mock_gr.Checkbox.assert_called()
480
+ call_kwargs = mock_gr.Checkbox.call_args[1]
481
+ assert "Images" in str(call_kwargs.get("label", ""))
482
+
483
+
484
+ def test_create_toggle_default_value():
485
+ """Toggle respects the default value parameter."""
486
+ checkbox_false = create_toggle("๐Ÿ”Š Audio", value=False, elem_id="toggle-audio")
487
+ call_kwargs = mock_gr.Checkbox.call_args[1]
488
+ assert call_kwargs.get("value") is False
489
+
490
+
491
+ def test_create_toggle_elem_id_generation():
492
+ """elem_id follows the pattern toggle-<label-without-emoji>."""
493
+ create_toggle("๐Ÿ–ผ๏ธ Images", value=True, elem_id="toggle-images")
494
+ call_kwargs = mock_gr.Checkbox.call_args[1]
495
+ assert call_kwargs.get("elem_id") == "toggle-images"
496
+
497
+
498
+ def test_create_voice_dropdown_all_choices():
499
+ """All 6 voice choices present in dropdown."""
500
+ dropdown = create_voice_dropdown()
501
+ call_kwargs = mock_gr.Dropdown.call_args[1]
502
+ choices = call_kwargs.get("choices", [])
503
+ assert len(choices) == 6
504
+
505
+
506
+ def test_create_voice_dropdown_default_value():
507
+ """Default value matches the first choice."""
508
+ create_voice_dropdown()
509
+ call_kwargs = mock_gr.Dropdown.call_args[1]
510
+ default = call_kwargs.get("value")
511
+ choices = call_kwargs.get("choices", [])
512
+ assert default == choices[0]
513
+
514
+
515
+ def test_create_voice_dropdown_elem_id():
516
+ """Voice dropdown elem_id is 'voice-dropdown'."""
517
+ create_voice_dropdown()
518
+ call_kwargs = mock_gr.Dropdown.call_args[1]
519
+ assert call_kwargs.get("elem_id") == "voice-dropdown"
520
+
521
+
522
+ def test_voice_map_all_six_entries():
523
+ """_VOICE_MAP has exactly 6 entries mapping display labels to instruct strings."""
524
+ assert len(_VOICE_MAP) == 6
525
+
526
+
527
+ def test_voice_map_instruct_strings_format():
528
+ """All _VOICE_MAP values are comma-separated gender, age format."""
529
+ for label, instruct in _VOICE_MAP.items():
530
+ parts = instruct.split(", ")
531
+ assert len(parts) == 2
532
+ assert parts[0] in ("female", "male")
533
+ assert parts[1] in ("young adult", "middle-aged", "senior")
534
+
535
+
536
+ def test_enable_phase2_returns_tuple():
537
+ """_enable_phase2() returns tuple of (Checkbox, Checkbox, Button, Dropdown, "") with interactive=True."""
538
+ result = _enable_phase2()
539
+ assert isinstance(result, tuple)
540
+ assert len(result) == 5
541
+ # Check that interactive=True was passed for each widget
542
+ for i in range(4):
543
+ mock_gr.__getitem__.assert_called()
544
+
545
+
546
+ def test_reset_to_idle_returns_tuple():
547
+ """_reset_to_idle() returns tuple with interactive=False, disabled CSS string."""
548
+ result = _reset_to_idle()
549
+ assert isinstance(result, tuple)
550
+ assert len(result) == 5
551
+ # Last element should be a CSS string (non-empty)
552
+ assert isinstance(result[4], str)
553
+ assert len(result[4]) > 0
554
+
555
+
556
+ def test_enable_language_dropdown_on_audio_true():
557
+ """Audio toggle ON โ†’ removes disabled CSS, enables dropdown."""
558
+ result = _enable_language_dropdown_on_audio(True)
559
+ assert isinstance(result, tuple)
560
+ # Should return (dropdown_update, "") โ€” empty CSS means enabled
561
+ assert result[1] == "" or len(result[1]) == 0
562
+
563
+
564
+ def test_enable_language_dropdown_on_audio_false():
565
+ """Audio toggle OFF โ†’ applies disabled CSS to voice dropdown."""
566
+ result = _enable_language_dropdown_on_audio(False)
567
+ assert isinstance(result, tuple)
568
+ # Should return (dropdown_update, css_string) โ€” non-empty CSS means disabled
569
+ assert isinstance(result[1], str)
570
+ assert len(result[1]) > 0
571
+ assert "europalex-btn-disabled" in result[1]
572
+ ```
573
+
574
+ ---
575
+
576
+ ### Task 5: app_test.py โ€” App Helpers + Async Generators
577
+
578
+ **Files:**
579
+ - Create: `tests/app_test.py`
580
+
581
+ ```python
582
+ """Tests for app.py helper functions and async generator handlers."""
583
+
584
+ from pathlib import Path
585
+ import types
586
+ import pytest
587
+ from unittest.mock import patch, MagicMock
588
+
589
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
590
+
591
+
592
+ # โ”€โ”€ transform_mock_cards โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
593
+
594
+ def test_transform_mock_cards_legacy_to_new_format():
595
+ """Legacy {"front": X, "back": Y} โ†’ new {"text": Y, "translation": X}."""
596
+ from app import transform_mock_cards
597
+
598
+ raw = [
599
+ {"front": "Sveiki", "back": "Hello"},
600
+ {"front": "Paldies", "back": "Thank you"},
601
+ ]
602
+ result = transform_mock_cards(raw)
603
+ assert result[0]["text"] == "Hello"
604
+ assert result[0]["translation"] == "Sveiki"
605
+ assert result[1]["text"] == "Thank you"
606
+ assert result[1]["translation"] == "Paldies"
607
+
608
+
609
+ def test_transform_mock_cards_empty_input():
610
+ """Empty input returns empty list."""
611
+ from app import transform_mock_cards
612
+
613
+ assert transform_mock_cards([]) == []
614
+
615
+
616
+ def test_transform_mock_cards_preserves_order():
617
+ """Multiple cards preserved in order."""
618
+ from app import transform_mock_cards
619
+
620
+ raw = [
621
+ {"front": "A1", "back": "B1"},
622
+ {"front": "A2", "back": "B2"},
623
+ {"front": "A3", "back": "B3"},
624
+ ]
625
+ result = transform_mock_cards(raw)
626
+ assert len(result) == 3
627
+ assert result[0]["text"] == "B1"
628
+ assert result[1]["text"] == "B2"
629
+ assert result[2]["text"] == "B3"
630
+
631
+
632
+ # โ”€โ”€ _progress_pct โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
633
+
634
+ def test_progress_pct_single_sentence():
635
+ """Single sentence (total=1): always 100% with 'complete' label."""
636
+ from app import _progress_pct
637
+
638
+ pct, label = _progress_pct(0, total=1)
639
+ assert pct == 70.0 # end_pct default
640
+ assert "complete" in label.lower()
641
+
642
+
643
+ def test_progress_pct_two_sentences():
644
+ """Two sentences: step 0 โ†’ ~50%, step 1 โ†’ 100%."""
645
+ from app import _progress_pct
646
+
647
+ pct0, _ = _progress_pct(0, total=2)
648
+ pct1, label1 = _progress_pct(1, total=2)
649
+ # Step 0: 15 + (1/2) * (70-15) = 15 + 27.5 = 42.5
650
+ assert abs(pct0 - 42.5) < 0.1
651
+ # Step 1: should be complete
652
+ assert "complete" in label1.lower()
653
+
654
+
655
+ def test_progress_pct_five_sentences():
656
+ """Five sentences: all steps verified for percentage and remaining count."""
657
+ from app import _progress_pct
658
+
659
+ total = 5
660
+ start_pct, end_pct = 15.0, 70.0
661
+ expected_pcts = [
662
+ round(start_pct + (1/5) * (end_pct - start_pct), 1), # step 0
663
+ round(start_pct + (2/5) * (end_pct - start_pct), 1), # step 1
664
+ round(start_pct + (3/5) * (end_pct - start_pct), 1), # step 2
665
+ round(start_pct + (4/5) * (end_pct - start_pct), 1), # step 3
666
+ end_pct, # step 4
667
+ ]
668
+
669
+ for i in range(total):
670
+ pct, label = _progress_pct(i, total=total)
671
+ assert abs(pct - expected_pcts[i]) < 0.1
672
+ if i < total - 1:
673
+ remaining = total - (i + 1)
674
+ assert f"{i + 1}/{total}" in label
675
+ assert f"{remaining} remaining" in label
676
+
677
+
678
+ # โ”€โ”€ generate_text_async โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
679
+
680
+ def test_generate_text_async_is_generator():
681
+ """generate_text_async is a generator function."""
682
+ from app import generate_text_async
683
+ assert types.isgeneratorfunction(generate_text_async)
684
+
685
+
686
+ def test_generate_text_async_yields_progress_and_cards(mock_llm_response_factory):
687
+ """Generator yields progress updates then card HTML when engine succeeds."""
688
+ from unittest.mock import patch, MagicMock
689
+ from app import generate_text_async
690
+
691
+ mock_engine = MagicMock()
692
+ mock_engine.generate.return_value = MagicMock(
693
+ generated_texts=["Hello.", "World."]
694
+ )
695
+
696
+ mock_pool = MagicMock()
697
+ mock_pool.get_english_engine.return_value = mock_engine
698
+
699
+ with patch("app.EnginePool", mock_pool):
700
+ with patch("app.EngineConfig.from_settings_yaml") as mock_config:
701
+ mock_config.return_value.batch_size = 2
702
+ yields = list(generate_text_async("test scenario", "A1", 2))
703
+
704
+ # Should yield at least 3 tuples: progress+empty, progress+empty, progress+cards_html
705
+ assert len(yields) >= 3
706
+ # Last yield should have card HTML (non-empty string)
707
+ last_progress, last_cards = yields[-1]
708
+ assert "Hello." in last_cards or "World." in last_cards
709
+
710
+
711
+ def test_generate_text_async_file_not_found_error():
712
+ """FileNotFoundError path โ†’ error message in output."""
713
+ from app import generate_text_async
714
+
715
+ with patch("app.EnginePool") as mock_pool:
716
+ mock_pool.side_effect = FileNotFoundError("model.gguf not found")
717
+ yields = list(generate_text_async("test", "A1", 2))
718
+
719
+ assert len(yields) >= 1
720
+ _, output = yields[0]
721
+ assert "Model file not found" in output or "model" in output.lower()
722
+
723
+
724
+ def test_generate_text_async_general_exception():
725
+ """General exception path โ†’ error message in output."""
726
+ from app import generate_text_async
727
+
728
+ with patch("app.EnginePool") as mock_pool:
729
+ mock_pool.side_effect = RuntimeError("GPU out of memory")
730
+ yields = list(generate_text_async("test", "A1", 2))
731
+
732
+ assert len(yields) >= 1
733
+ _, output = yields[0]
734
+ assert "Failed to initialize" in output or "Setup error" in output
735
+
736
+
737
+ # โ”€โ”€ generate_media_async โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
738
+
739
+ def test_generate_media_async_is_generator():
740
+ """generate_media_async is a generator function."""
741
+ from app import generate_media_async
742
+ assert types.isgeneratorfunction(generate_media_async)
743
+
744
+
745
+ def test_generate_media_async_yields_per_sentence(mock_english_texts):
746
+ """Cards grow with each yield during translation phase."""
747
+ from unittest.mock import patch, MagicMock
748
+ from app import generate_media_async
749
+
750
+ # Set up Phase 1 texts
751
+ import app as app_module
752
+ original = list(app_module._phase1_texts)
753
+ app_module._phase1_texts = list(mock_english_texts)
754
+
755
+ try:
756
+ mock_engine = MagicMock()
757
+ mock_engine._translate_single.return_value = "Sveiki."
758
+
759
+ mock_pool = MagicMock()
760
+ mock_pool.get_translation_engine.return_value = mock_engine
761
+
762
+ with patch("app.EnginePool", mock_pool):
763
+ with patch("app.EngineConfig.from_settings_yaml") as mock_config:
764
+ mock_config.return_value.batch_size = 3
765
+ yields = list(generate_media_async("test", "A1", 3))
766
+
767
+ # Each translation step yields (progress, cards)
768
+ # At least 3 yields for 3 sentences + final yield
769
+ card_yields = [(p, c) for p, c in yields if "translation" in c.lower() or "Sveiki" in c]
770
+ assert len(card_yields) >= 1
771
+ finally:
772
+ app_module._phase1_texts = original
773
+
774
+
775
+ def test_generate_media_async_tts_toggle():
776
+ """Audio toggle ON โ†’ yields audio generation progress at 70%."""
777
+ from unittest.mock import patch, MagicMock
778
+ from app import generate_media_async
779
+
780
+ import app as app_module
781
+ original = list(app_module._phase1_texts)
782
+ app_module._phase1_texts = ["Hello."]
783
+
784
+ try:
785
+ mock_trans_engine = MagicMock()
786
+ mock_trans_engine._translate_single.return_value = "Sveiki."
787
+
788
+ mock_audio_result = MagicMock()
789
+ mock_audio_result.audio_paths = ["/tmp/audio_0.wav"]
790
+
791
+ mock_tts_engine = MagicMock()
792
+ mock_tts_engine.synthesize.return_value = mock_audio_result
793
+
794
+ mock_pool = MagicMock()
795
+ mock_pool.get_translation_engine.return_value = mock_trans_engine
796
+ mock_pool.get_tts_engine.return_value = mock_tts_engine
797
+
798
+ with patch("app.EnginePool", mock_pool):
799
+ with patch("app.EngineConfig.from_settings_yaml") as mock_config:
800
+ mock_config.return_value.batch_size = 1
801
+ yields = list(generate_media_async(
802
+ "test", "A1", 1,
803
+ target_language="Latvian",
804
+ include_audio=True,
805
+ include_images=False,
806
+ voice="female, young adult",
807
+ ))
808
+
809
+ # Check that audio generation progress was yielded
810
+ progress_labels = [p for p, _ in yields]
811
+ assert any("audio" in str(p).lower() for p in progress_labels)
812
+ finally:
813
+ app_module._phase1_texts = original
814
+
815
+
816
+ def test_generate_media_async_images_toggle():
817
+ """Images toggle ON โ†’ yields image generation progress at 85%."""
818
+ from unittest.mock import patch, MagicMock
819
+ from app import generate_media_async
820
+
821
+ import app as app_module
822
+ original = list(app_module._phase1_texts)
823
+ app_module._phase1_texts = ["Hello."]
824
+
825
+ try:
826
+ mock_trans_engine = MagicMock()
827
+ mock_trans_engine._translate_single.return_value = "Sveiki."
828
+
829
+ mock_img_result = MagicMock()
830
+ mock_img_result.image_paths = ["/tmp/image_0.png"]
831
+
832
+ mock_pool = MagicMock()
833
+ mock_pool.get_translation_engine.return_value = mock_trans_engine
834
+
835
+ with patch("app.EnginePool", mock_pool):
836
+ with patch("app.EngineConfig.from_settings_yaml") as mock_config:
837
+ mock_config.return_value.batch_size = 1
838
+ yields = list(generate_media_async(
839
+ "test", "A1", 1,
840
+ target_language="Latvian",
841
+ include_audio=False,
842
+ include_images=True,
843
+ ))
844
+
845
+ progress_labels = [p for p, _ in yields]
846
+ assert any("image" in str(p).lower() for p in progress_labels)
847
+ finally:
848
+ app_module._phase1_texts = original
849
+
850
+
851
+ def test_generate_media_async_missing_phase1_texts():
852
+ """No Phase 1 texts โ†’ error message."""
853
+ from app import generate_media_async
854
+
855
+ import app as app_module
856
+ original = list(app_module._phase1_texts)
857
+ app_module._phase1_texts = []
858
+
859
+ try:
860
+ yields = list(generate_media_async("test", "A1", 3))
861
+ assert len(yields) >= 1
862
+ _, output = yields[0]
863
+ assert "Phase 1" in output or "generate text first" in output.lower()
864
+ finally:
865
+ app_module._phase1_texts = original
866
+ ```
867
+
868
+ ---
869
+
870
+ ### Task 6: audio_gen_test.py โ€” TTSEngine
871
+
872
+ **Files:**
873
+ - Create: `tests/audio_gen_test.py`
874
+
875
+ ```python
876
+ """Tests for core.audio_gen.TTSEngine."""
877
+
878
+ from pathlib import Path
879
+ from unittest.mock import patch, MagicMock, call
880
+ import pytest
881
+
882
+ from core.audio_gen import TTSEngine
883
+ from core.types import AudioResult
884
+
885
+
886
+ def test_tts_engine_synthesize_success(mock_audio_paths, temp_output_dir):
887
+ """Success path: mock model returns audio data โ†’ .wav file written, path in result."""
888
+ engine = TTSEngine(device="cpu")
889
+
890
+ mock_model = MagicMock()
891
+ # Simulate model returning numpy-like audio data (48000 samples at 24kHz = 2 seconds)
892
+ import numpy as np
893
+ mock_model.generate.return_value = [np.zeros(48000, dtype=np.float32)]
894
+
895
+ with patch.object(engine, "_load_model"):
896
+ engine._model = mock_model
897
+ engine._loaded = True
898
+
899
+ result = engine.synthesize(
900
+ texts=["Hello.", "World."],
901
+ output_dir=temp_output_dir,
902
+ language="English",
903
+ instruct="female, young adult",
904
+ )
905
+
906
+ assert isinstance(result, AudioResult)
907
+ assert len(result.audio_paths) == 2
908
+ assert result.audio_paths[0] is not None
909
+ assert result.audio_paths[1] is not None
910
+ assert Path(result.audio_paths[0]).exists()
911
+ assert Path(result.audio_paths[1]).exists()
912
+ assert mock_model.generate.call_count == 2
913
+
914
+
915
+ def test_tts_engine_synthesize_failure_path(temp_output_dir):
916
+ """Failure path: mock model raises exception โ†’ None in result list."""
917
+ engine = TTSEngine(device="cpu")
918
+
919
+ mock_model = MagicMock()
920
+ mock_model.generate.side_effect = RuntimeError("GPU OOM")
921
+
922
+ with patch.object(engine, "_load_model"):
923
+ engine._model = mock_model
924
+ engine._loaded = True
925
+
926
+ result = engine.synthesize(
927
+ texts=["Hello.", "World."],
928
+ output_dir=temp_output_dir,
929
+ )
930
+
931
+ assert len(result.audio_paths) == 2
932
+ assert result.audio_paths[0] is None
933
+ assert result.audio_paths[1] is None
934
+
935
+
936
+ def test_tts_engine_synthesize_empty_input(temp_output_dir):
937
+ """Empty input list: returns empty AudioResult."""
938
+ engine = TTSEngine(device="cpu")
939
+
940
+ with patch.object(engine, "_load_model"):
941
+ result = engine.synthesize(
942
+ texts=[],
943
+ output_dir=temp_output_dir,
944
+ )
945
+
946
+ assert isinstance(result, AudioResult)
947
+ assert result.audio_paths == []
948
+
949
+
950
+ def test_tts_engine_synthesize_language_and_instruct_passed(temp_output_dir):
951
+ """Language and instruct parameters passed to model.generate()."""
952
+ engine = TTSEngine(device="cpu")
953
+
954
+ mock_model = MagicMock()
955
+ mock_model.generate.return_value = [MagicMock()]
956
+
957
+ with patch.object(engine, "_load_model"):
958
+ engine._model = mock_model
959
+ engine._loaded = True
960
+
961
+ engine.synthesize(
962
+ texts=["Test"],
963
+ output_dir=temp_output_dir,
964
+ language="Latvian",
965
+ instruct="male, middle-aged",
966
+ )
967
+
968
+ mock_model.generate.assert_called_once()
969
+ call_kwargs = mock_model.generate.call_args[1]
970
+ assert call_kwargs["language"] == "Latvian"
971
+ assert call_kwargs["instruct"] == "male, middle-aged"
972
+
973
+
974
+ def test_tts_engine_synthesize_default_instruct(temp_output_dir):
975
+ """Default instruct is 'female, young adult' when omitted."""
976
+ engine = TTSEngine(device="cpu")
977
+
978
+ mock_model = MagicMock()
979
+ mock_model.generate.return_value = [MagicMock()]
980
+
981
+ with patch.object(engine, "_load_model"):
982
+ engine._model = mock_model
983
+ engine._loaded = True
984
+
985
+ engine.synthesize(
986
+ texts=["Test"],
987
+ output_dir=temp_output_dir,
988
+ language="English",
989
+ )
990
+
991
+ call_kwargs = mock_model.generate.call_args[1]
992
+ assert call_kwargs["instruct"] == "female, young adult"
993
+
994
+
995
+ def test_tts_engine_unload():
996
+ """Model deleted, _loaded reset to False, torch.cuda.empty_cache() called."""
997
+ engine = TTSEngine(device="cuda")
998
+
999
+ mock_model = MagicMock()
1000
+ engine._model = mock_model
1001
+ engine._loaded = True
1002
+
1003
+ with patch("torch.cuda.empty_cache") as mock_empty:
1004
+ engine.unload()
1005
+
1006
+ assert engine._model is None
1007
+ assert engine._loaded is False
1008
+ mock_empty.assert_called_once()
1009
+
1010
+
1011
+ def test_tts_engine_unload_already_unloaded():
1012
+ """Calling unload when already unloaded does not error."""
1013
+ engine = TTSEngine(device="cuda")
1014
+ engine._model = None
1015
+ engine._loaded = False
1016
+
1017
+ # Should not raise
1018
+ engine.unload()
1019
+ assert engine._loaded is False
1020
+ ```
1021
+
1022
+ ---
1023
+
1024
+ ### Task 7: image_gen_test.py โ€” ImageGenEngine
1025
+
1026
+ **Files:**
1027
+ - Create: `tests/image_gen_test.py`
1028
+
1029
+ ```python
1030
+ """Tests for core.image_gen.ImageGenEngine."""
1031
+
1032
+ from pathlib import Path
1033
+ from unittest.mock import patch, MagicMock
1034
+ import pytest
1035
+
1036
+ from core.image_gen import ImageGenEngine
1037
+ from core.types import ImageResult
1038
+
1039
+
1040
+ def test_imagegen_engine_generate_success(mock_image_paths, temp_output_dir):
1041
+ """Success path: mock pipeline returns images โ†’ .png file written, path in result."""
1042
+ engine = ImageGenEngine(device="cpu")
1043
+
1044
+ mock_pipeline = MagicMock()
1045
+ # Simulate pipeline returning a list with one PIL-like image object
1046
+ mock_image = MagicMock()
1047
+ mock_image.size = (512, 512)
1048
+ mock_pipeline.return_value = [mock_image]
1049
+
1050
+ with patch.object(engine, "_load_pipeline"):
1051
+ engine._pipeline = mock_pipeline
1052
+ engine._loaded = True
1053
+
1054
+ result = engine.generate(
1055
+ prompts=["A cat.", "A dog."],
1056
+ output_dir=temp_output_dir,
1057
+ )
1058
+
1059
+ assert isinstance(result, ImageResult)
1060
+ assert len(result.image_paths) == 2
1061
+ assert result.image_paths[0] is not None
1062
+ assert result.image_paths[1] is not None
1063
+ assert Path(result.image_paths[0]).exists()
1064
+ assert Path(result.image_paths[1]).exists()
1065
+
1066
+
1067
+ def test_imagegen_engine_generate_failure_path(temp_output_dir):
1068
+ """Failure path: mock pipeline raises exception โ†’ None in result list."""
1069
+ engine = ImageGenEngine(device="cpu")
1070
+
1071
+ mock_pipeline = MagicMock()
1072
+ mock_pipeline.side_effect = RuntimeError("OOM")
1073
+
1074
+ with patch.object(engine, "_load_pipeline"):
1075
+ engine._pipeline = mock_pipeline
1076
+ engine._loaded = True
1077
+
1078
+ result = engine.generate(
1079
+ prompts=["A cat.", "A dog."],
1080
+ output_dir=temp_output_dir,
1081
+ )
1082
+
1083
+ assert len(result.image_paths) == 2
1084
+ assert result.image_paths[0] is None
1085
+ assert result.image_paths[1] is None
1086
+
1087
+
1088
+ def test_imagegen_engine_generate_empty_input(temp_output_dir):
1089
+ """Empty input list: returns empty ImageResult."""
1090
+ engine = ImageGenEngine(device="cpu")
1091
+
1092
+ with patch.object(engine, "_load_pipeline"):
1093
+ result = engine.generate(
1094
+ prompts=[],
1095
+ output_dir=temp_output_dir,
1096
+ )
1097
+
1098
+ assert isinstance(result, ImageResult)
1099
+ assert result.image_paths == []
1100
+
1101
+
1102
+ def test_imagegen_engine_generate_empty_output_warning(temp_output_dir):
1103
+ """Pipeline returns empty list โ†’ None path logged."""
1104
+ engine = ImageGenEngine(device="cpu")
1105
+
1106
+ mock_pipeline = MagicMock()
1107
+ mock_pipeline.return_value = [] # Empty output
1108
+
1109
+ with patch.object(engine, "_load_pipeline"):
1110
+ engine._pipeline = mock_pipeline
1111
+ engine._loaded = True
1112
+
1113
+ result = engine.generate(
1114
+ prompts=["A cat."],
1115
+ output_dir=temp_output_dir,
1116
+ )
1117
+
1118
+ assert len(result.image_paths) == 1
1119
+ assert result.image_paths[0] is None
1120
+
1121
+
1122
+ def test_imagegen_engine_unload():
1123
+ """Pipeline deleted, _loaded reset to False, torch.cuda.empty_cache() called."""
1124
+ engine = ImageGenEngine(device="cuda")
1125
+
1126
+ mock_pipeline = MagicMock()
1127
+ engine._pipeline = mock_pipeline
1128
+ engine._loaded = True
1129
+
1130
+ with patch("torch.cuda.empty_cache") as mock_empty:
1131
+ engine.unload()
1132
+
1133
+ assert engine._pipeline is None
1134
+ assert engine._loaded is False
1135
+ mock_empty.assert_called_once()
1136
+
1137
+
1138
+ def test_imagegen_engine_unload_already_unloaded():
1139
+ """Calling unload when already unloaded does not error."""
1140
+ engine = ImageGenEngine(device="cuda")
1141
+ engine._pipeline = None
1142
+ engine._loaded = False
1143
+
1144
+ engine.unload()
1145
+ assert engine._loaded is False
1146
+ ```
1147
+
1148
+ ---
1149
+
1150
+ ### Task 8: text_gen_test.py โ€” extract_sentences + generate_sentences
1151
+
1152
+ **Files:**
1153
+ - Create: `tests/text_gen_test.py`
1154
+
1155
+ ```python
1156
+ """Tests for core.text_gen.extract_sentences and generate_sentences.
1157
+
1158
+ Merged from count_enforcement_test.py and extract_sentences_test.py.
1159
+ All tests use mocking โ€” no LLM inference needed.
1160
+ """
1161
+
1162
+ import pytest
1163
+ from unittest.mock import MagicMock
1164
+
1165
+ from core.text_gen import extract_sentences, generate_sentences
1166
+ from core.types import CEFRLevel, ValidationError
1167
+
1168
+
1169
+ # โ”€โ”€ extract_sentences โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1170
+
1171
+ def test_extract_sentences_basic_numbered_format():
1172
+ """Basic numbered format: '1. Hello.\n2. World.' โ†’ ['Hello.', 'World.']"""
1173
+ result = extract_sentences("1. Hello world.\n2. Goodbye world.")
1174
+ assert len(result) == 2
1175
+ assert result[0] == "Hello world."
1176
+ assert result[1] == "Goodbye world."
1177
+
1178
+
1179
+ def test_extract_sentences_thinking_tag_stripping():
1180
+ """Strips <thinking> tags before parsing."""
1181
+ raw = "<thinking>some thoughts\nmore thoughts</thinking>\n1. Sentence one.\n2. Sentence two."
1182
+ result = extract_sentences(raw)
1183
+ assert len(result) == 2
1184
+ assert result[0] == "Sentence one."
1185
+ assert result[1] == "Sentence two."
1186
+
1187
+
1188
+ def test_extract_sentences_mixed_punctuation():
1189
+ """Sentences ending with ., ?, ! all recognized."""
1190
+ raw = "1. Hello.\n2. How are you?\n3. What a day!"
1191
+ result = extract_sentences(raw)
1192
+ assert len(result) == 3
1193
+ assert result[0] == "Hello."
1194
+ assert result[1] == "How are you?"
1195
+ assert result[2] == "What a day!"
1196
+
1197
+
1198
+ def test_extract_sentences_zero_sentences_raises():
1199
+ """Zero numbered sentences raises ValidationError."""
1200
+ with pytest.raises(ValidationError):
1201
+ extract_sentences("No numbered lines here.\nJust plain text.")
1202
+
1203
+
1204
+ def test_extract_sentences_uncapped_20_sentences():
1205
+ """20 numbered sentences all returned โ€” no upper cap."""
1206
+ lines = "\n".join(f"{i}. Sentence {i}." for i in range(1, 21))
1207
+ result = extract_sentences(lines)
1208
+ assert len(result) == 20
1209
+ assert result[0] == "Sentence 1."
1210
+ assert result[19] == "Sentence 20."
1211
+
1212
+
1213
+ def test_extract_sentences_ignores_non_numbered_lines():
1214
+ """Non-numbered lines silently ignored, not discarded."""
1215
+ raw = "Some intro text.\n1. Valid sentence.\nMore text.\n2. Another valid."
1216
+ result = extract_sentences(raw)
1217
+ assert len(result) == 2
1218
+ assert result[0] == "Valid sentence."
1219
+ assert result[1] == "Another valid."
1220
+
1221
+
1222
+ def test_extract_sentences_dot_numbering_format():
1223
+ """Dot numbering (1., 2.) format recognized."""
1224
+ raw = "1. First.\n2. Second.\n3. Third."
1225
+ result = extract_sentences(raw)
1226
+ assert len(result) == 3
1227
+ assert result == ["First.", "Second.", "Third."]
1228
+
1229
+
1230
+ def test_extract_sentences_paren_numbering_format():
1231
+ """Paren numbering (1), 2)) format recognized."""
1232
+ raw = "1) First.\n2) Second.\n3) Third."
1233
+ result = extract_sentences(raw)
1234
+ assert len(result) == 3
1235
+ assert result == ["First.", "Second.", "Third."]
1236
+
1237
+
1238
+ def test_extract_sentences_empty_after_tag_stripping_raises():
1239
+ """Raw text contains only thinking tags โ†’ ValidationError."""
1240
+ with pytest.raises(ValidationError):
1241
+ extract_sentences("<thinking>only reasoning</thinking>")
1242
+
1243
+
1244
+ # โ”€โ”€ generate_sentences โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1245
+
1246
+ def test_generate_sentences_success_first_try(mock_llm_response_factory):
1247
+ """Success on first try with exact batch_size."""
1248
+ mock_llm = MagicMock()
1249
+ mock_llm.create_chat_completion.return_value = mock_llm_response_factory(
1250
+ "1. Hello.\n2. World."
1251
+ )
1252
+
1253
+ result = generate_sentences(
1254
+ scenario="test",
1255
+ cefr_level=CEFRLevel.A1,
1256
+ batch_size=2,
1257
+ llm=mock_llm,
1258
+ )
1259
+ assert len(result) == 2
1260
+ assert result[0] == "Hello."
1261
+ assert result[1] == "World."
1262
+
1263
+
1264
+ def test_generate_sentences_uncapped_extraction():
1265
+ """More sentences than batch_size: returns all extracted (up to batch_size cap)."""
1266
+ mock_llm = MagicMock()
1267
+ mock_llm.create_chat_completion.return_value = {
1268
+ "choices": [{"message": {"content": "1. First.\n2. Second.\n3. Third.\n4. Fourth."}}]
1269
+ }
1270
+
1271
+ result = generate_sentences(
1272
+ scenario="test",
1273
+ cefr_level=CEFRLevel.A1,
1274
+ batch_size=2,
1275
+ llm=mock_llm,
1276
+ )
1277
+ # batch_size is a cap: returns first 2
1278
+ assert len(result) == 2
1279
+
1280
+
1281
+ def test_generate_sentences_retry_on_fewer_than_batch():
1282
+ """Retries when fewer than batch_size sentences on first call."""
1283
+ mock_llm = MagicMock()
1284
+ mock_llm.create_chat_completion.side_effect = [
1285
+ {"choices": [{"message": {"content": "1. Only one sentence."}}]},
1286
+ {"choices": [{"message": {"content": "2. Second.\n3. Third.\n4. Fourth."}}]},
1287
+ ]
1288
+
1289
+ result = generate_sentences(
1290
+ scenario="greetings",
1291
+ cefr_level=CEFRLevel.A1,
1292
+ batch_size=3,
1293
+ llm=mock_llm,
1294
+ )
1295
+ assert len(result) == 3
1296
+ assert mock_llm.create_chat_completion.call_count == 2
1297
+
1298
+
1299
+ def test_generate_sentences_fallback_after_exhausted_retries():
1300
+ """Returns whatever was produced after retries exhausted."""
1301
+ mock_llm = MagicMock()
1302
+ # First call: 1 sentence. Second call: 2 sentences (still < batch_size=3)
1303
+ mock_llm.create_chat_completion.side_effect = [
1304
+ {"choices": [{"message": {"content": "1. Only one."}}]},
1305
+ {"choices": [{"message": {"content": "2. Second.\n3. Third."}}]},
1306
+ ]
1307
+
1308
+ result = generate_sentences(
1309
+ scenario="greetings",
1310
+ cefr_level=CEFRLevel.A1,
1311
+ batch_size=3,
1312
+ llm=mock_llm,
1313
+ )
1314
+ assert len(result) == 2
1315
+
1316
+
1317
+ def test_generate_sentences_thinking_tags_handled():
1318
+ """LLM output containing thinking tags handled correctly."""
1319
+ mock_llm = MagicMock()
1320
+ mock_llm.create_chat_completion.return_value = {
1321
+ "choices": [{"message": {"content": "<thinking>reasoning</thinking>\n1. Hello.\n2. World."}}]
1322
+ }
1323
+
1324
+ result = generate_sentences(
1325
+ scenario="test",
1326
+ cefr_level=CEFRLevel.A1,
1327
+ batch_size=2,
1328
+ llm=mock_llm,
1329
+ )
1330
+ assert len(result) == 2
1331
+ assert result[0] == "Hello."
1332
+
1333
+
1334
+ def test_generate_sentences_question_sentences_preserved():
1335
+ """Question sentences preserved in output."""
1336
+ mock_llm = MagicMock()
1337
+ mock_llm.create_chat_completion.return_value = {
1338
+ "choices": [{"message": {"content": "1. What is this?\n2. It is a cat."}}]
1339
+ }
1340
+
1341
+ result = generate_sentences(
1342
+ scenario="test",
1343
+ cefr_level=CEFRLevel.A1,
1344
+ batch_size=2,
1345
+ llm=mock_llm,
1346
+ )
1347
+ assert len(result) == 2
1348
+ assert result[0] == "What is this?"
1349
+ ```
1350
+
1351
+ ---
1352
+
1353
+ ### Task 9: engine_test.py โ€” MiniCPMTextEngine, LlamaCppTextEngine, EnginePool
1354
+
1355
+ **Files:**
1356
+ - Create: `tests/engine_test.py`
1357
+
1358
+ ```python
1359
+ """Tests for core.engine engines: MiniCPMTextEngine, LlamaCppTextEngine, EnginePool.
1360
+
1361
+ Merged from translation_retry_test.py. All tests mock the LLM โ€” no model inference.
1362
+ """
1363
+
1364
+ import pytest
1365
+ from unittest.mock import patch, MagicMock
1366
+
1367
+ from core.types import CEFRLevel, TextResult, ValidationError
1368
+ from core.engine import MiniCPMTextEngine, LlamaCppTextEngine, EnginePool
1369
+
1370
+
1371
+ # โ”€โ”€ MiniCPMTextEngine โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1372
+
1373
+ def test_minicpm_generate_calls_llm(mock_llm_response_factory):
1374
+ """generate() calls llm.create_chat_completion and wraps in TextResult."""
1375
+ mock_llm = MagicMock()
1376
+ mock_llm.create_chat_completion.return_value = mock_llm_response_factory(
1377
+ "1. Hello.\n2. World."
1378
+ )
1379
+
1380
+ with patch.object(MiniCPMTextEngine, "_load_model"):
1381
+ engine = MiniCPMTextEngine.__new__(MiniCPMTextEngine)
1382
+ engine._llm = mock_llm
1383
+ engine._loaded = True
1384
+
1385
+ result = engine.generate(
1386
+ texts=[], # empty = generation mode
1387
+ scenario="test",
1388
+ cefr_level=CEFRLevel.A1,
1389
+ batch_size=2,
1390
+ )
1391
+
1392
+ assert isinstance(result, TextResult)
1393
+ assert len(result.generated_texts) == 2
1394
+ assert result.generated_texts[0] == "Hello."
1395
+ mock_llm.create_chat_completion.assert_called_once()
1396
+
1397
+
1398
+ def test_minicpm_generate_propagates_validation_error():
1399
+ """ValidationError from text_gen propagate through generate()."""
1400
+ from core.text_gen import ValidationError as TextGenValidationError
1401
+
1402
+ mock_llm = MagicMock()
1403
+ # LLM returns content that yields 0 sentences after parsing
1404
+ mock_llm.create_chat_completion.return_value = {"choices": [{"message": {"content": "no numbers here"}}]}
1405
+
1406
+ with patch.object(MiniCPMTextEngine, "_load_model"):
1407
+ engine = MiniCPMTextEngine.__new__(MiniCPMTextEngine)
1408
+ engine._llm = mock_llm
1409
+ engine._loaded = True
1410
+
1411
+ # Should raise ValidationError after retries exhausted
1412
+ with pytest.raises((ValidationError, TextGenValidationError)):
1413
+ engine.generate(
1414
+ texts=[],
1415
+ scenario="test",
1416
+ cefr_level=CEFRLevel.A1,
1417
+ batch_size=2,
1418
+ )
1419
+
1420
+
1421
+ # โ”€โ”€ LlamaCppTextEngine._is_valid_translation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1422
+
1423
+ def test_is_valid_translation_valid():
1424
+ """Valid: non-empty single line, no English words."""
1425
+ with patch.object(LlamaCppTextEngine, "_load_model"):
1426
+ engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
1427
+ engine._loaded = True
1428
+
1429
+ assert engine._is_valid_translation("Sveiki.") is True
1430
+ assert engine._is_valid_translation("Labrฤซt!") is True
1431
+ assert engine._is_valid_translation("Paldies, ka jautฤji.") is True
1432
+
1433
+
1434
+ def test_is_valid_translation_invalid_empty():
1435
+ """Invalid: empty string or whitespace-only."""
1436
+ with patch.object(LlamaCppTextEngine, "_load_model"):
1437
+ engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
1438
+ engine._loaded = True
1439
+
1440
+ assert engine._is_valid_translation("") is False
1441
+ assert engine._is_valid_translation(" ") is False
1442
+
1443
+
1444
+ def test_is_valid_translation_invalid_english_words():
1445
+ """Invalid: contains English words (model echoed back)."""
1446
+ with patch.object(LlamaCppTextEngine, "_load_model"):
1447
+ engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
1448
+ engine._loaded = True
1449
+
1450
+ assert engine._is_valid_translation("This is the translation") is False
1451
+ assert engine._is_valid_translation("Translate this sentence") is False
1452
+
1453
+
1454
+ def test_is_valid_translation_invalid_multiline():
1455
+ """Invalid: multiline output."""
1456
+ with patch.object(LlamaCppTextEngine, "_load_model"):
1457
+ engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
1458
+ engine._loaded = True
1459
+
1460
+ assert engine._is_valid_translation("Line1\nLine2") is False
1461
+
1462
+
1463
+ # โ”€โ”€ LlamaCppTextEngine._translate_single โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€๏ฟฝ๏ฟฝ๏ฟฝโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1464
+
1465
+ def test_translate_single_success():
1466
+ """Valid translation returned on first attempt."""
1467
+ mock_llm = MagicMock()
1468
+ mock_llm.create_chat_completion.return_value = {"choices": [{"message": {"content": "Sveiki."}}]}
1469
+
1470
+ with patch.object(LlamaCppTextEngine, "_load_model"):
1471
+ engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
1472
+ engine._llm = mock_llm
1473
+ engine._loaded = True
1474
+
1475
+ result = engine._translate_single("Hello.", CEFRLevel.A1)
1476
+
1477
+ assert result == "Sveiki."
1478
+ assert mock_llm.create_chat_completion.call_count == 1
1479
+
1480
+
1481
+ def test_translate_single_retry_on_invalid():
1482
+ """Retry when first output invalid (contains English word), second succeeds."""
1483
+ mock_llm = MagicMock()
1484
+ mock_llm.create_chat_completion.side_effect = [
1485
+ {"choices": [{"message": {"content": "This is the English text"}}]},
1486
+ {"choices": [{"message": {"content": "Paldies."}}]},
1487
+ ]
1488
+
1489
+ with patch.object(LlamaCppTextEngine, "_load_model"):
1490
+ engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
1491
+ engine._llm = mock_llm
1492
+ engine._loaded = True
1493
+
1494
+ result = engine._translate_single("Thank you.", CEFRLevel.A1)
1495
+
1496
+ assert result == "Paldies."
1497
+ assert mock_llm.create_chat_completion.call_count == 2
1498
+
1499
+
1500
+ def test_translate_single_exhausted_retries_fallback():
1501
+ """Exhausted retries โ†’ fallback to original English text."""
1502
+ mock_llm = MagicMock()
1503
+ mock_llm.create_chat_completion.return_value = {"choices": [{"message": {"content": ""}}]}
1504
+
1505
+ with patch.object(LlamaCppTextEngine, "_load_model"):
1506
+ engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
1507
+ engine._llm = mock_llm
1508
+ engine._loaded = True
1509
+
1510
+ result = engine._translate_single("Hello.", CEFRLevel.A1)
1511
+
1512
+ assert result == "Hello." # fallback to original English
1513
+ assert mock_llm.create_chat_completion.call_count == 3
1514
+
1515
+
1516
+ def test_translate_single_multiline_rejected():
1517
+ """Multiline output rejected, triggers retry."""
1518
+ mock_llm = MagicMock()
1519
+ mock_llm.create_chat_completion.side_effect = [
1520
+ {"choices": [{"message": {"content": "Line1\nLine2"}}]}, # invalid: multiline
1521
+ {"choices": [{"message": {"content": "Valid translation."}}]}, # valid
1522
+ ]
1523
+
1524
+ with patch.object(LlamaCppTextEngine, "_load_model"):
1525
+ engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
1526
+ engine._llm = mock_llm
1527
+ engine._loaded = True
1528
+
1529
+ result = engine._translate_single("Hello.", CEFRLevel.A1)
1530
+
1531
+ assert result == "Valid translation."
1532
+ assert mock_llm.create_chat_completion.call_count == 2
1533
+
1534
+
1535
+ # โ”€โ”€ LlamaCppTextEngine.generate โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1536
+
1537
+ def test_generate_calls_per_sentence():
1538
+ """generate() calls _translate_single for each input text."""
1539
+ mock_llm = MagicMock()
1540
+ responses = [
1541
+ {"choices": [{"message": {"content": "Sveiki."}}]},
1542
+ {"choices": [{"message": {"content": "Kฤ tu esi?"}}]},
1543
+ {"choices": [{"message": {"content": "Paldies."}}]},
1544
+ ]
1545
+ mock_llm.create_chat_completion.side_effect = responses
1546
+
1547
+ with patch.object(LlamaCppTextEngine, "_load_model"):
1548
+ engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
1549
+ engine._llm = mock_llm
1550
+ engine._loaded = True
1551
+
1552
+ result = engine.generate(
1553
+ texts=["Hello.", "How are you?", "Thank you."],
1554
+ scenario="greetings",
1555
+ cefr_level=CEFRLevel.A1,
1556
+ batch_size=3,
1557
+ )
1558
+
1559
+ assert len(result.generated_texts) == 3
1560
+ assert result.generated_texts[0] == "Sveiki."
1561
+ assert result.generated_texts[1] == "Kฤ tu esi?"
1562
+ assert result.generated_texts[2] == "Paldies."
1563
+ assert mock_llm.create_chat_completion.call_count == 3
1564
+
1565
+
1566
+ # โ”€โ”€ EnginePool โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1567
+
1568
+ def test_engine_pool_get_creates_singleton():
1569
+ """First get() creates a new EnginePool instance."""
1570
+ from core.types import EngineConfig
1571
+
1572
+ config = EngineConfig(batch_size=3, target_language="Latvian")
1573
+ pool = EnginePool.get(config)
1574
+ assert isinstance(pool, EnginePool)
1575
+
1576
+
1577
+ def test_engine_pool_get_returns_same_instance():
1578
+ """Second get() returns the same instance."""
1579
+ from core.types import EngineConfig
1580
+
1581
+ config1 = EngineConfig(batch_size=3, target_language="Latvian")
1582
+ pool1 = EnginePool.get(config1)
1583
+ pool2 = EnginePool.get(config1)
1584
+ assert pool1 is pool2
1585
+
1586
+
1587
+ def test_engine_pool_reset_clears_singleton():
1588
+ """reset() clears singleton and unloads engines."""
1589
+ from core.types import EngineConfig
1590
+
1591
+ config = EngineConfig(batch_size=3, target_language="Latvian")
1592
+ pool1 = EnginePool.get(config)
1593
+
1594
+ # Create a second reference
1595
+ pool2 = EnginePool.get(config)
1596
+ assert pool1 is pool2
1597
+
1598
+ EnginePool.reset()
1599
+
1600
+ # After reset, new get() should return a different instance
1601
+ pool3 = EnginePool.get(config)
1602
+ assert pool3 is not pool1
1603
+ ```
1604
+
1605
+ ---
1606
+
1607
+ ### Task 10: pipeline_test.py โ€” Phase 2 Orchestration
1608
+
1609
+ **Files:**
1610
+ - Create: `tests/pipeline_test.py`
1611
+
1612
+ ```python
1613
+ """Tests for core.pipeline.generate_phase2() orchestration.
1614
+
1615
+ Mocks EnginePool and individual engines. Verifies orchestration flow,
1616
+ progress percentages, and CardData assembly.
1617
+ """
1618
+
1619
+ import pytest
1620
+ from unittest.mock import patch, MagicMock, PropertyMock
1621
+ import types
1622
+
1623
+ from core.types import CEFRLevel
1624
+
1625
+
1626
+ def test_generate_phase2_is_generator():
1627
+ """generate_phase2 is a generator function."""
1628
+ from core.pipeline import generate_phase2
1629
+ assert types.isgeneratorfunction(generate_phase2)
1630
+
1631
+
1632
+ def test_generate_phase2_translation_only(mock_english_texts, mock_spanish_translations):
1633
+ """Translation-only: yields progress updates per sentence, final CardData list with translations."""
1634
+ from core.pipeline import generate_phase2
1635
+ from core.types import CardData
1636
+
1637
+ # Build mock engine that returns translated texts
1638
+ mock_engine = MagicMock()
1639
+ mock_engine.generate.return_value = MagicMock(
1640
+ generated_texts=list(mock_spanish_translations)
1641
+ )
1642
+
1643
+ mock_pool = MagicMock()
1644
+ mock_pool.get_translation_engine.return_value = mock_engine
1645
+
1646
+ with patch("core.pipeline.EnginePool", mock_pool):
1647
+ with patch("core.pipeline.EngineConfig.from_settings_yaml") as mock_config:
1648
+ mock_config.return_value.batch_size = 3
1649
+ yields = list(generate_phase2(
1650
+ english_texts=list(mock_english_texts),
1651
+ scenario="test",
1652
+ cefr_level=CEFRLevel.A1,
1653
+ batch_size=3,
1654
+ target_language="Spanish",
1655
+ include_audio=False,
1656
+ include_images=False,
1657
+ ))
1658
+
1659
+ # Should yield at least: progress prepare, progress per sentence, final complete
1660
+ assert len(yields) >= 5 # 20% prepare + 3 translation steps + final
1661
+
1662
+ # Last yield should have CardData list
1663
+ last_progress, last_result = yields[-1]
1664
+ assert isinstance(last_result, list)
1665
+ assert len(last_result) == 3
1666
+ assert all(isinstance(card, CardData) for card in last_result)
1667
+
1668
+
1669
+ def test_generate_phase2_translation_plus_tts(mock_english_texts, mock_spanish_translations):
1670
+ """Translation+TTS: additional yield at 70% for audio generation, CardData includes audio_paths."""
1671
+ from core.pipeline import generate_phase2
1672
+ from core.types import AudioResult
1673
+
1674
+ mock_engine = MagicMock()
1675
+ mock_engine.generate.return_value = MagicMock(
1676
+ generated_texts=list(mock_spanish_translations)
1677
+ )
1678
+
1679
+ mock_audio_result = MagicMock()
1680
+ mock_audio_result.audio_paths = ["/tmp/audio_0.wav", "/tmp/audio_1.wav", "/tmp/audio_2.wav"]
1681
+
1682
+ mock_tts_engine = MagicMock()
1683
+ mock_tts_engine.synthesize.return_value = mock_audio_result
1684
+
1685
+ mock_pool = MagicMock()
1686
+ mock_pool.get_translation_engine.return_value = mock_engine
1687
+ mock_pool.get_tts_engine.return_value = mock_tts_engine
1688
+
1689
+ with patch("core.pipeline.EnginePool", mock_pool):
1690
+ with patch("core.pipeline.EngineConfig.from_settings_yaml") as mock_config:
1691
+ mock_config.return_value.batch_size = 3
1692
+ yields = list(generate_phase2(
1693
+ english_texts=list(mock_english_texts),
1694
+ scenario="test",
1695
+ cefr_level=CEFRLevel.A1,
1696
+ batch_size=3,
1697
+ target_language="Spanish",
1698
+ include_audio=True,
1699
+ include_images=False,
1700
+ ))
1701
+
1702
+ # Check that audio generation progress was yielded
1703
+ progress_labels = [p for p, _ in yields]
1704
+ assert any("audio" in str(p).lower() for p in progress_labels)
1705
+
1706
+ # Final CardData should have audio_paths
1707
+ last_progress, last_result = yields[-1]
1708
+ assert len(last_result) == 3
1709
+ assert all(card.audio_path is not None for card in last_result)
1710
+
1711
+
1712
+ def test_generate_phase2_progress_percentages():
1713
+ """Progress percentages: 20% prepare, 15-70% translation steps, 100% complete."""
1714
+ from core.pipeline import generate_phase2
1715
+
1716
+ mock_engine = MagicMock()
1717
+ mock_engine.generate.return_value = MagicMock(generated_texts=["A.", "B."])
1718
+
1719
+ mock_pool = MagicMock()
1720
+ mock_pool.get_translation_engine.return_value = mock_engine
1721
+
1722
+ with patch("core.pipeline.EnginePool", mock_pool):
1723
+ with patch("core.pipeline.EngineConfig.from_settings_yaml") as mock_config:
1724
+ mock_config.return_value.batch_size = 2
1725
+ yields = list(generate_phase2(
1726
+ english_texts=["A.", "B."],
1727
+ scenario="test",
1728
+ cefr_level=CEFRLevel.A1,
1729
+ batch_size=2,
1730
+ target_language="Spanish",
1731
+ include_audio=False,
1732
+ include_images=False,
1733
+ ))
1734
+
1735
+ # First yield should be ~20% (prepare)
1736
+ first_progress, _ = yields[0]
1737
+ assert "20" in str(first_progress) or "Preparing" in str(first_progress).lower()
1738
+
1739
+ # Progress values increase during translation
1740
+ progress_values = []
1741
+ for p, _ in yields:
1742
+ if isinstance(p, (int, float)):
1743
+ progress_values.append(p)
1744
+ if len(progress_values) >= 2:
1745
+ assert progress_values[-1] >= progress_values[0]
1746
+
1747
+
1748
+ def test_generate_phase2_validation_error_propagation():
1749
+ """If translation fails after retries, ValidationError is raised and not caught."""
1750
+ from core.pipeline import generate_phase2
1751
+ from core.types import ValidationError
1752
+
1753
+ mock_engine = MagicMock()
1754
+ # Simulate engine raising ValidationError
1755
+ mock_engine.generate.side_effect = ValidationError("Translation failed", raw_output="bad output")
1756
+
1757
+ mock_pool = MagicMock()
1758
+ mock_pool.get_translation_engine.return_value = mock_engine
1759
+
1760
+ with patch("core.pipeline.EnginePool", mock_pool):
1761
+ with patch("core.pipeline.EngineConfig.from_settings_yaml") as mock_config:
1762
+ mock_config.return_value.batch_size = 1
1763
+ with pytest.raises(ValidationError):
1764
+ list(generate_phase2(
1765
+ english_texts=["A."],
1766
+ scenario="test",
1767
+ cefr_level=CEFRLevel.A1,
1768
+ batch_size=1,
1769
+ target_language="Spanish",
1770
+ include_audio=False,
1771
+ include_images=False,
1772
+ ))
1773
+ ```
1774
+
1775
+ ---
1776
+
1777
+ ### Task 11: Update AGENTS.md โ€” Testing Expectations Section
1778
+
1779
+ **Files:**
1780
+ - Modify: `AGENTS.md` (Testing Expectations section)
1781
+
1782
+ Replace the existing "Testing Expectations" section with:
1783
+
1784
+ ```markdown
1785
+ ## Testing Expectations
1786
+
1787
+ ### Pytest Test Suite
1788
+
1789
+ All tests use pytest. Run the full suite before committing:
1790
+
1791
+ ```bash
1792
+ uv run pytest tests/ -v
1793
+ ```
1794
+
1795
+ **Test file naming convention:** `*_test.py` โ€” one file per source module, flat structure in `tests/`:
1796
+
1797
+ | Test File | Covers |
1798
+ |---|---|
1799
+ | `conftest.py` | Shared fixtures (mock data, paths, temp dirs) |
1800
+ | `smoke_test.py` | Import validation + Pydantic model construction |
1801
+ | `cards_test.py` | Card HTML rendering functions |
1802
+ | `widgets_test.py` | Widget creation and UI state helpers |
1803
+ | `app_test.py` | App async generators and helper functions |
1804
+ | `audio_gen_test.py` | TTSEngine (TTS audio generation) |
1805
+ | `image_gen_test.py` | ImageGenEngine (image generation) |
1806
+ | `engine_test.py` | MiniCPMTextEngine, LlamaCppTextEngine, EnginePool |
1807
+ | `pipeline_test.py` | Phase 2 orchestration |
1808
+ | `text_gen_test.py` | Sentence extraction + text generation |
1809
+
1810
+ ### Writing Tests
1811
+
1812
+ - Use fixtures from `tests/conftest.py` for mock data and paths.
1813
+ - Mock all GPU/model code via `unittest.mock.patch` โ€” no real inference needed.
1814
+ - Use assertions (`assert`, `pytest.raises`) instead of print statements.
1815
+ - Generator functions consumed via `list(handler(...))` to capture all yields.
1816
+ - Real `.wav` and `.png` files from `tests/test_outputs/` serve as file-existence fixtures.
1817
+
1818
+ ### Smoke Tests
1819
+
1820
+ Run `uv run pytest tests/smoke_test.py -v` for a quick sanity check: imports all modules, validates Pydantic models, and checks that the Gradio app can be constructed without errors.
1821
+
1822
+ ### Inline Tests (Legacy)
1823
+
1824
+ The old `if __name__ == "__main__":` inline test pattern is deprecated. All tests should be in `*_test.py` files under `tests/`. Do not add new inline tests.
1825
+ ```
1826
+
1827
+ ---
1828
+
1829
+ ### Task 12: Update README.md โ€” Add Running Tests Section
1830
+
1831
+ **Files:**
1832
+ - Modify: `README.md` (add after setup/installation section)
1833
+
1834
+ Add a new "Running Tests" section:
1835
+
1836
+ ```markdown
1837
+ ### Running Tests
1838
+
1839
+ All tests use pytest. Run the full suite:
1840
+
1841
+ ```bash
1842
+ # Run all tests
1843
+ uv run pytest tests/ -v
1844
+
1845
+ # Run specific test file
1846
+ uv run pytest tests/cards_test.py -v
1847
+
1848
+ # Run with coverage
1849
+ uv run pytest tests/ -v --cov=core --cov=frontend --cov=app.py
1850
+ ```
1851
+
1852
+ The test suite mocks all GPU/model code โ€” no model weights or GPU required to run tests.
1853
+ ```
1854
+
1855
+ ---
1856
+
1857
+ ## Execution Order
1858
+
1859
+ 1. **conftest.py** (Task 1) โ€” foundation for all other tests
1860
+ 2. **smoke_test.py** (Task 2) โ€” validates imports before testing dependent modules
1861
+ 3. **cards_test.py** (Task 3) โ€” pure HTML rendering, no dependencies
1862
+ 4. **widgets_test.py** (Task 4) โ€” widget creation, independent of app logic
1863
+ 5. **app_test.py** (Task 5) โ€” depends on cards_test patterns for generator testing
1864
+ 6. **audio_gen_test.py** (Task 6) โ€” engine mocking pattern established
1865
+ 7. **image_gen_test.py** (Task 7) โ€” mirrors audio_gen pattern
1866
+ 8. **text_gen_test.py** (Task 8) โ€” merges old tests, pure functions
1867
+ 9. **engine_test.py** (Task 9) โ€” merges translation_retry_test, complex mocking
1868
+ 10. **pipeline_test.py** (Task 10) โ€” highest-level orchestration test
1869
+ 11. **AGENTS.md update** (Task 11) โ€” documentation
1870
+ 12. **README.md update** (Task 12) โ€” documentation
1871
+
1872
+ ## Post-Migration Cleanup (after all tasks pass)
1873
+
1874
+ After the full test suite passes (`uv run pytest tests/ -v`):
1875
+
1876
+ ```bash
1877
+ # Remove old inline test files
1878
+ rm tests/count_enforcement_test.py
1879
+ rm tests/extract_sentences_test.py
1880
+ rm tests/translation_retry_test.py
1881
+ rm tests/progression_test.py
1882
+ ```
1883
+
1884
+ Then update `README.md` file tree section to reflect new test file structure.
1885
+
1886
+ ## Verification Checklist
1887
+
1888
+ - [ ] `uv run pytest tests/ -v` passes with 0 failures
1889
+ - [ ] All old inline test files removed
1890
+ - [ ] AGENTS.md testing section updated
1891
+ - [ ] README.md has "Running Tests" section
1892
+ - [ ] No import errors in any test file (verified by smoke_test.py)
tests/app_test.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for app.py helper functions and async generator handlers."""
2
+
3
+ from pathlib import Path
4
+ import inspect
5
+ import pytest
6
+ from unittest.mock import patch, MagicMock
7
+
8
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
9
+
10
+
11
+ # โ”€โ”€ transform_mock_cards โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
12
+
13
+ def test_transform_mock_cards_legacy_to_new_format():
14
+ """Legacy {"front": X, "back": Y} โ†’ new {"text": Y, "translation": X}."""
15
+ from app import transform_mock_cards
16
+
17
+ raw = [
18
+ {"front": "Sveiki", "back": "Hello"},
19
+ {"front": "Paldies", "back": "Thank you"},
20
+ ]
21
+ result = transform_mock_cards(raw)
22
+ assert result[0]["text"] == "Hello"
23
+ assert result[0]["translation"] == "Sveiki"
24
+ assert result[1]["text"] == "Thank you"
25
+ assert result[1]["translation"] == "Paldies"
26
+
27
+
28
+ def test_transform_mock_cards_empty_input():
29
+ """Empty input returns empty list."""
30
+ from app import transform_mock_cards
31
+
32
+ assert transform_mock_cards([]) == []
33
+
34
+
35
+ def test_transform_mock_cards_preserves_order():
36
+ """Multiple cards preserved in order."""
37
+ from app import transform_mock_cards
38
+
39
+ raw = [
40
+ {"front": "A1", "back": "B1"},
41
+ {"front": "A2", "back": "B2"},
42
+ {"front": "A3", "back": "B3"},
43
+ ]
44
+ result = transform_mock_cards(raw)
45
+ assert len(result) == 3
46
+ assert result[0]["text"] == "B1"
47
+ assert result[1]["text"] == "B2"
48
+ assert result[2]["text"] == "B3"
49
+
50
+
51
+ # โ”€โ”€ _progress_pct โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
52
+
53
+ def test_progress_pct_single_sentence():
54
+ """Single sentence (total=1): returns end_pct with 'complete' label."""
55
+ from app import _progress_pct
56
+
57
+ pct, label = _progress_pct(0, total=1)
58
+ assert pct == 70.0
59
+ assert "complete" in label.lower()
60
+
61
+
62
+ def test_progress_pct_two_sentences():
63
+ """Two sentences: step 0 โ†’ ~42.5%, step 1 โ†’ complete."""
64
+ from app import _progress_pct
65
+
66
+ pct0, _ = _progress_pct(0, total=2)
67
+ pct1, label1 = _progress_pct(1, total=2)
68
+ # Step 0: 15 + (1/2) * (70-15) = 15 + 27.5 = 42.5
69
+ assert abs(pct0 - 42.5) < 0.1
70
+ # Step 1: should be complete
71
+ assert "complete" in label1.lower()
72
+
73
+
74
+ def test_progress_pct_five_sentences():
75
+ """Five sentences: all steps verified for percentage and remaining count."""
76
+ from app import _progress_pct
77
+
78
+ total = 5
79
+ start_pct, end_pct = 15.0, 70.0
80
+ expected_pcts = [
81
+ round(start_pct + (1/5) * (end_pct - start_pct), 1), # step 0
82
+ round(start_pct + (2/5) * (end_pct - start_pct), 1), # step 1
83
+ round(start_pct + (3/5) * (end_pct - start_pct), 1), # step 2
84
+ round(start_pct + (4/5) * (end_pct - start_pct), 1), # step 3
85
+ end_pct, # step 4
86
+ ]
87
+
88
+ for i in range(total):
89
+ pct, label = _progress_pct(i, total=total)
90
+ assert abs(pct - expected_pcts[i]) < 0.1
91
+ if i < total - 1:
92
+ remaining = total - (i + 1)
93
+ assert f"{i + 1}/{total}" in label
94
+ assert f"{remaining} remaining" in label
95
+
96
+
97
+ # โ”€โ”€ generate_text_async โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
98
+
99
+ def test_generate_text_async_is_generator():
100
+ """generate_text_async is a generator function."""
101
+ from app import generate_text_async
102
+ assert inspect.isgeneratorfunction(generate_text_async)
103
+
104
+
105
+ def test_generate_text_async_yields_progress_and_cards(mock_llm_response_factory):
106
+ """Generator yields progress updates then card HTML when engine succeeds."""
107
+ from unittest.mock import patch, MagicMock
108
+ from app import generate_text_async
109
+
110
+ mock_engine = MagicMock()
111
+ mock_engine.generate.return_value = MagicMock(
112
+ generated_texts=["Hello.", "World."]
113
+ )
114
+
115
+ mock_pool_instance = MagicMock()
116
+ mock_pool_instance.get_english_engine.return_value = mock_engine
117
+
118
+ with patch("core.engine.EnginePool") as mock_pool_class:
119
+ mock_pool_class.get.return_value = mock_pool_instance
120
+ with patch("core.types.EngineConfig.from_settings_yaml") as mock_config:
121
+ mock_config.return_value.batch_size = 2
122
+ yields = list(generate_text_async("test scenario", "A1", 2))
123
+
124
+ # Should yield at least 3 tuples: progress+empty, progress+empty, progress+cards_html
125
+ assert len(yields) >= 3
126
+ # Last yield should have card HTML (non-empty string)
127
+ last_progress, last_cards = yields[-1]
128
+ assert "Hello." in last_cards or "World." in last_cards
129
+
130
+
131
+ def test_generate_text_async_file_not_found_error():
132
+ """FileNotFoundError path โ†’ error message in output."""
133
+ from app import generate_text_async
134
+
135
+ with patch("core.engine.EnginePool") as mock_pool_class:
136
+ mock_pool_class.get.side_effect = FileNotFoundError("model.gguf not found")
137
+ yields = list(generate_text_async("test", "A1", 2))
138
+
139
+ assert len(yields) >= 1
140
+ _, output = yields[0]
141
+ assert "Model file not found" in output or "model" in output.lower()
142
+
143
+
144
+ def test_generate_text_async_general_exception():
145
+ """General exception path โ†’ error message in output."""
146
+ from app import generate_text_async
147
+
148
+ with patch("core.engine.EnginePool") as mock_pool_class:
149
+ mock_pool_class.get.side_effect = RuntimeError("GPU out of memory")
150
+ yields = list(generate_text_async("test", "A1", 2))
151
+
152
+ assert len(yields) >= 1
153
+ _, output = yields[0]
154
+ assert "Failed to initialize" in output or "Setup error" in output
155
+
156
+
157
+ # โ”€โ”€ generate_media_async โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
158
+
159
+ def test_generate_media_async_is_generator():
160
+ """generate_media_async is a generator function."""
161
+ from app import generate_media_async
162
+ assert inspect.isgeneratorfunction(generate_media_async)
163
+
164
+
165
+ def test_generate_media_async_yields_per_sentence(mock_english_texts):
166
+ """Cards grow with each yield during translation phase."""
167
+ from unittest.mock import patch, MagicMock
168
+ from app import generate_media_async
169
+
170
+ # Set up Phase 1 texts
171
+ import app as app_module
172
+ original = list(app_module._phase1_texts)
173
+ app_module._phase1_texts = list(mock_english_texts)
174
+
175
+ try:
176
+ mock_engine = MagicMock()
177
+ mock_engine._translate_single.return_value = "Sveiki."
178
+
179
+ mock_pool_instance = MagicMock()
180
+ mock_pool_instance.get_translation_engine.return_value = mock_engine
181
+
182
+ with patch("core.engine.EnginePool") as mock_pool_class:
183
+ mock_pool_class.get.return_value = mock_pool_instance
184
+ yields = list(generate_media_async("test", "A1", 3))
185
+
186
+ # Each translation step yields (progress, cards)
187
+ # At least 3 yields for 3 sentences + final yield
188
+ card_yields = [(p, c) for p, c in yields if "translation" in c.lower() or "Sveiki" in c]
189
+ assert len(card_yields) >= 1
190
+ finally:
191
+ app_module._phase1_texts = original
192
+
193
+
194
+ def test_generate_media_async_missing_phase1_texts():
195
+ """No Phase 1 texts โ†’ error message."""
196
+ from app import generate_media_async
197
+
198
+ import app as app_module
199
+ original = list(app_module._phase1_texts)
200
+ app_module._phase1_texts = []
201
+
202
+ try:
203
+ yields = list(generate_media_async("test", "A1", 3))
204
+ assert len(yields) >= 1
205
+ _, output = yields[0]
206
+ assert "Phase 1" in output or "generate text first" in output.lower()
207
+ finally:
208
+ app_module._phase1_texts = original
tests/audio_gen_test.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for core.audio_gen.TTSEngine."""
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import patch, MagicMock, call
5
+ import pytest
6
+
7
+ from core.audio_gen import TTSEngine
8
+ from core.types import AudioResult
9
+
10
+
11
+ def test_tts_engine_synthesize_success(mock_audio_paths, temp_output_dir):
12
+ """Success path: mock model returns audio data โ†’ .wav file written, path in result."""
13
+ engine = TTSEngine(device="cpu")
14
+
15
+ mock_model = MagicMock()
16
+ # Simulate model returning numpy-like audio data (48000 samples at 24kHz = 2 seconds)
17
+ import numpy as np
18
+ mock_model.generate.return_value = [np.zeros(48000, dtype=np.float32)]
19
+
20
+ with patch.object(engine, "_load_model"):
21
+ engine._model = mock_model
22
+ engine._loaded = True
23
+
24
+ result = engine.synthesize(
25
+ texts=["Hello.", "World."],
26
+ output_dir=temp_output_dir,
27
+ language="English",
28
+ instruct="female, young adult",
29
+ )
30
+
31
+ assert isinstance(result, AudioResult)
32
+ assert len(result.audio_paths) == 2
33
+ assert result.audio_paths[0] is not None
34
+ assert result.audio_paths[1] is not None
35
+ assert Path(result.audio_paths[0]).exists()
36
+ assert Path(result.audio_paths[1]).exists()
37
+ assert mock_model.generate.call_count == 2
38
+
39
+
40
+ def test_tts_engine_synthesize_failure_path(temp_output_dir):
41
+ """Failure path: mock model raises exception โ†’ None in result list."""
42
+ engine = TTSEngine(device="cpu")
43
+
44
+ mock_model = MagicMock()
45
+ mock_model.generate.side_effect = RuntimeError("GPU OOM")
46
+
47
+ with patch.object(engine, "_load_model"):
48
+ engine._model = mock_model
49
+ engine._loaded = True
50
+
51
+ result = engine.synthesize(
52
+ texts=["Hello.", "World."],
53
+ output_dir=temp_output_dir,
54
+ )
55
+
56
+ assert len(result.audio_paths) == 2
57
+ assert result.audio_paths[0] is None
58
+ assert result.audio_paths[1] is None
59
+
60
+
61
+ def test_tts_engine_synthesize_empty_input(temp_output_dir):
62
+ """Empty input list: returns empty AudioResult."""
63
+ engine = TTSEngine(device="cpu")
64
+
65
+ with patch.object(engine, "_load_model"):
66
+ result = engine.synthesize(
67
+ texts=[],
68
+ output_dir=temp_output_dir,
69
+ )
70
+
71
+ assert isinstance(result, AudioResult)
72
+ assert result.audio_paths == []
73
+
74
+
75
+ def test_tts_engine_synthesize_language_and_instruct_passed(temp_output_dir):
76
+ """Language and instruct parameters passed to model.generate()."""
77
+ engine = TTSEngine(device="cpu")
78
+
79
+ mock_model = MagicMock()
80
+ mock_model.generate.return_value = [MagicMock()]
81
+
82
+ with patch.object(engine, "_load_model"):
83
+ engine._model = mock_model
84
+ engine._loaded = True
85
+
86
+ engine.synthesize(
87
+ texts=["Test"],
88
+ output_dir=temp_output_dir,
89
+ language="Latvian",
90
+ instruct="male, middle-aged",
91
+ )
92
+
93
+ mock_model.generate.assert_called_once()
94
+ call_kwargs = mock_model.generate.call_args[1]
95
+ assert call_kwargs["language"] == "Latvian"
96
+ assert call_kwargs["instruct"] == "male, middle-aged"
97
+
98
+
99
+ def test_tts_engine_synthesize_default_instruct(temp_output_dir):
100
+ """Default instruct is 'female, young adult' when omitted."""
101
+ engine = TTSEngine(device="cpu")
102
+
103
+ mock_model = MagicMock()
104
+ mock_model.generate.return_value = [MagicMock()]
105
+
106
+ with patch.object(engine, "_load_model"):
107
+ engine._model = mock_model
108
+ engine._loaded = True
109
+
110
+ engine.synthesize(
111
+ texts=["Test"],
112
+ output_dir=temp_output_dir,
113
+ language="English",
114
+ )
115
+
116
+ call_kwargs = mock_model.generate.call_args[1]
117
+ assert call_kwargs["instruct"] == "female, young adult"
118
+
119
+
120
+ def test_tts_engine_unload():
121
+ """Model deleted, _loaded reset to False, torch.cuda.empty_cache() called."""
122
+ engine = TTSEngine(device="cuda")
123
+
124
+ mock_model = MagicMock()
125
+ engine._model = mock_model
126
+ engine._loaded = True
127
+
128
+ with patch("torch.cuda.empty_cache") as mock_empty:
129
+ engine.unload()
130
+
131
+ assert engine._model is None
132
+ assert engine._loaded is False
133
+ mock_empty.assert_called_once()
134
+
135
+
136
+ def test_tts_engine_unload_already_unloaded():
137
+ """Calling unload when already unloaded does not error."""
138
+ engine = TTSEngine(device="cuda")
139
+ engine._model = None
140
+ engine._loaded = False
141
+
142
+ # Should not raise
143
+ engine.unload()
144
+ assert engine._loaded is False
145
+
146
+
147
+ def test_tts_engine_synthesize_empty_audio_data(temp_output_dir):
148
+ """Model returns empty audio data โ†’ None path."""
149
+ engine = TTSEngine(device="cpu")
150
+
151
+ mock_model = MagicMock()
152
+ mock_model.generate.return_value = [] # Empty output
153
+
154
+ with patch.object(engine, "_load_model"):
155
+ engine._model = mock_model
156
+ engine._loaded = True
157
+
158
+ result = engine.synthesize(
159
+ texts=["Test"],
160
+ output_dir=temp_output_dir,
161
+ )
162
+
163
+ assert len(result.audio_paths) == 1
164
+ assert result.audio_paths[0] is None
tests/cards_test.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for frontend.ui.cards card rendering functions."""
2
+
3
+ from pathlib import Path
4
+ import pytest
5
+
6
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
7
+ from frontend.ui.cards import render_card_html, generate_cards_html, generate_progress_html
8
+
9
+
10
+ @pytest.fixture
11
+ def mock_audio_paths():
12
+ audio_dir = PROJECT_ROOT / "tests" / "test_outputs" / "audio"
13
+ return [str(audio_dir / f"audio_{i}.wav") for i in range(3)]
14
+
15
+
16
+ @pytest.fixture
17
+ def mock_image_paths():
18
+ image_dir = PROJECT_ROOT / "tests" / "test_outputs" / "images"
19
+ return [str(image_dir / f"image_{i}.png") for i in range(3)]
20
+
21
+
22
+ # โ”€โ”€ render_card_html โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
23
+
24
+ def test_render_card_html_placeholder_mode():
25
+ """Placeholder mode: English on front, dashed placeholder back."""
26
+ card = {
27
+ "text": "Hello world.",
28
+ "translation": "",
29
+ "cefr_level": "A1",
30
+ }
31
+ html = render_card_html(card, include_image=False, include_audio=False, rotation=0, placeholder_back=True)
32
+ assert "Hello world." in html
33
+ assert "card-placeholder-back" in html
34
+
35
+
36
+ def test_render_card_html_normal_mode():
37
+ """Normal mode: translation on front, English on back."""
38
+ card = {
39
+ "text": "Hello world.",
40
+ "translation": "Sveiki pasaule.",
41
+ "cefr_level": "A1",
42
+ }
43
+ html = render_card_html(card, include_image=False, include_audio=False, rotation=0, placeholder_back=False)
44
+ assert "Sveiki pasaule." in html
45
+ assert "Hello world." in html
46
+
47
+
48
+ def test_render_card_html_with_existing_image(mock_image_paths):
49
+ """Existing image file โ†’ <img> tag in HTML."""
50
+ card = {
51
+ "text": "A cat.",
52
+ "translation": "Kaฤทis.",
53
+ "image_path": mock_image_paths[0],
54
+ }
55
+ html = render_card_html(card, include_image=True, include_audio=False, rotation=0, placeholder_back=False)
56
+ assert "<img" in html
57
+
58
+
59
+ def test_render_card_html_with_missing_image():
60
+ """Missing image file โ†’ placeholder emoji."""
61
+ card = {
62
+ "text": "A cat.",
63
+ "translation": "Kaฤทis.",
64
+ "image_path": "/nonexistent/path.png",
65
+ }
66
+ html = render_card_html(card, include_image=True, include_audio=False, rotation=0, placeholder_back=False)
67
+ assert "<img" not in html
68
+
69
+
70
+ def test_render_card_html_with_existing_audio(mock_audio_paths):
71
+ """Existing audio file โ†’ <audio> element in HTML."""
72
+ card = {
73
+ "text": "Hello.",
74
+ "translation": "Sveiki.",
75
+ "audio_path": mock_audio_paths[0],
76
+ }
77
+ html = render_card_html(card, include_image=False, include_audio=True, rotation=0, placeholder_back=False)
78
+ assert "<audio" in html
79
+
80
+
81
+ def test_render_card_html_with_missing_audio():
82
+ """Missing audio file โ†’ play button."""
83
+ card = {
84
+ "text": "Hello.",
85
+ "translation": "Sveiki.",
86
+ "audio_path": "/nonexistent/path.wav",
87
+ }
88
+ html = render_card_html(card, include_image=False, include_audio=True, rotation=0, placeholder_back=False)
89
+ assert "<audio" not in html
90
+ assert "media-btn" in html
91
+
92
+
93
+ def test_render_card_html_rotation_applied():
94
+ """Rotation parameter applied to transform style."""
95
+ card = {"text": "Hello", "translation": ""}
96
+ html = render_card_html(card, include_image=False, include_audio=False, rotation=3.5, placeholder_back=False)
97
+ assert "rotate(3.5deg)" in html
98
+
99
+
100
+ # โ”€โ”€ generate_cards_html โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
101
+
102
+ def test_generate_cards_html_empty_list():
103
+ """Empty cards list โ†’ 'No cards' message."""
104
+ html = generate_cards_html([], include_image=False, include_audio=False)
105
+ assert "No cards" in html or "<div" in html
106
+
107
+
108
+ def test_generate_cards_html_single_card():
109
+ """Single card renders without rotation variation issues."""
110
+ cards = [{"text": "Hello", "translation": "Sveiki"}]
111
+ html = generate_cards_html(cards, include_image=False, include_audio=False)
112
+ assert "Hello" in html
113
+ assert "Sveiki" in html
114
+
115
+
116
+ def test_generate_cards_html_multi_card_rotation_distribution():
117
+ """Multiple cards get varied rotation angles for spread-on-desk effect."""
118
+ cards = [{"text": f"Sentence {i}", "translation": f"Tulkojums {i}"} for i in range(5)]
119
+ html = generate_cards_html(cards, include_image=False, include_audio=False)
120
+ # All sentences present
121
+ for i in range(5):
122
+ assert f"Sentence {i}" in html
123
+
124
+
125
+ def test_generate_cards_html_image_only():
126
+ """include_image=True, include_audio=False โ†’ images only."""
127
+ cards = [{"text": "A.", "translation": "B.", "image_path": str(PROJECT_ROOT / "tests" / "test_outputs" / "images" / "image_0.png")}]
128
+ html = generate_cards_html(cards, include_image=True, include_audio=False)
129
+ assert "<img" in html
130
+
131
+
132
+ def test_generate_cards_html_audio_only():
133
+ """include_image=False, include_audio=True โ†’ audio only."""
134
+ cards = [{"text": "A.", "translation": "B.", "audio_path": str(PROJECT_ROOT / "tests" / "test_outputs" / "audio" / "audio_0.wav")}]
135
+ html = generate_cards_html(cards, include_image=False, include_audio=True)
136
+ assert "<audio" in html
137
+
138
+
139
+ def test_generate_cards_html_both_media():
140
+ """Both image and audio toggles โ†’ both media boxes present."""
141
+ cards = [{
142
+ "text": "A.", "translation": "B.",
143
+ "image_path": str(PROJECT_ROOT / "tests" / "test_outputs" / "images" / "image_0.png"),
144
+ "audio_path": str(PROJECT_ROOT / "tests" / "test_outputs" / "audio" / "audio_0.wav"),
145
+ }]
146
+ html = generate_cards_html(cards, include_image=True, include_audio=True)
147
+ assert "<img" in html
148
+ assert "<audio" in html
149
+
150
+
151
+ def test_generate_cards_html_neither_media():
152
+ """Both toggles off โ†’ no media boxes."""
153
+ cards = [{"text": "A.", "translation": "B."}]
154
+ html = generate_cards_html(cards, include_image=False, include_audio=False)
155
+ assert "<img" not in html
156
+ assert "<audio" not in html
157
+
158
+
159
+ def test_generate_cards_html_placeholder_back_mode():
160
+ """placeholder_back=True โ†’ dashed placeholder instead of translation."""
161
+ cards = [{"text": "Hello", "translation": ""}]
162
+ html = generate_cards_html(cards, include_image=False, include_audio=False, placeholder_back=True)
163
+ assert "card-placeholder-back" in html
164
+
165
+
166
+ # โ”€โ”€ generate_progress_html โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
167
+
168
+ def test_generate_progress_html_zero_percent():
169
+ """0% โ†’ empty string (hidden by Gradio)."""
170
+ result = generate_progress_html(0, "")
171
+ assert result == ""
172
+
173
+
174
+ def test_generate_progress_html_mid_progress_color():
175
+ """10-59% โ†’ brown bar."""
176
+ html = generate_progress_html(50, "Working...")
177
+ assert "width: 50%" in html
178
+
179
+
180
+ def test_generate_progress_html_60_percent_brown():
181
+ """Exactly 60% โ†’ brown bar (threshold for dark brown is >60)."""
182
+ html = generate_progress_html(60, "Almost done...")
183
+ assert "#a0845c" in html
184
+
185
+
186
+ def test_generate_progress_html_61_percent_dark_brown():
187
+ """61% โ†’ dark brown bar (threshold is > 60)."""
188
+ html = generate_progress_html(61, "Almost done...")
189
+ assert "#8a6c4a" in html
190
+
191
+
192
+ def test_generate_progress_html_100_percent_complete():
193
+ """100% โ†’ dark brown bar, green 'complete' text."""
194
+ html = generate_progress_html(100, "Complete!")
195
+ assert "width: 100%" in html
196
+ assert "#4CAF50" in html or "green" in html.lower() or "#2a6e2a" in html
tests/conftest.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared pytest fixtures for EuropaLex test suite."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
8
+
9
+
10
+ @pytest.fixture
11
+ def mock_english_texts():
12
+ """Phase 1 English sentences."""
13
+ return [
14
+ "I love eating fresh fruits.",
15
+ "She enjoys cooking pasta.",
16
+ "The chef prepared a delicious meal.",
17
+ ]
18
+
19
+
20
+ @pytest.fixture
21
+ def mock_spanish_translations():
22
+ """Phase 2 Spanish translations."""
23
+ return [
24
+ "Me encanta comer frutas frescas.",
25
+ "Le encanta cocinar pasta.",
26
+ "El chef preparรณ una comida deliciosa.",
27
+ ]
28
+
29
+
30
+ @pytest.fixture
31
+ def mock_audio_paths():
32
+ """Real .wav paths from tests/test_outputs/audio/ for file-existence tests."""
33
+ audio_dir = PROJECT_ROOT / "tests" / "test_outputs" / "audio"
34
+ return [str(audio_dir / f"audio_{i}.wav") for i in range(3)]
35
+
36
+
37
+ @pytest.fixture
38
+ def mock_image_paths():
39
+ """Real .png paths from tests/test_outputs/images/ for file-existence tests."""
40
+ image_dir = PROJECT_ROOT / "tests" / "test_outputs" / "images"
41
+ return [str(image_dir / f"image_{i}.png") for i in range(3)]
42
+
43
+
44
+ @pytest.fixture
45
+ def temp_output_dir(tmp_path):
46
+ """Temporary directory for TTS/image generation tests, auto-cleaned."""
47
+ output = tmp_path / "output"
48
+ output.mkdir(parents=True)
49
+ return output
50
+
51
+
52
+ @pytest.fixture
53
+ def mock_llm_response_factory():
54
+ """Factory to build LLM response dicts: {"choices": [{"message": {"content": "..."}}]}."""
55
+
56
+ def _factory(content: str):
57
+ return {"choices": [{"message": {"content": content}}]}
58
+
59
+ return _factory
tests/count_enforcement_test.py DELETED
@@ -1,93 +0,0 @@
1
- """Quick inline test for core.text_gen sentence extraction.
2
-
3
- Tests extract_sentences and generate_sentences (via mock) with sample data.
4
- No LLM call needed โ€” tests pure parsing logic and retry orchestration.
5
- """
6
-
7
- import sys
8
- from pathlib import Path
9
-
10
- sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
11
-
12
-
13
- def test_extract_sentences_basic():
14
- """Test basic numbered format extraction."""
15
- from core.text_gen import extract_sentences
16
- result = extract_sentences("1. Hello world.\n2. Goodbye world.")
17
- assert len(result) == 2
18
- assert result[0] == "Hello world."
19
- assert result[1] == "Goodbye world."
20
- print("test_extract_sentences_basic: PASS")
21
-
22
-
23
- def test_extract_sentences_thinking_tags():
24
- """Test thinking tag stripping."""
25
- from core.text_gen import extract_sentences
26
- raw = "<thinking>some thoughts</thinking>\n1. Sentence one.\n2. Sentence two."
27
- result = extract_sentences(raw)
28
- assert len(result) == 2
29
- assert result[0] == "Sentence one."
30
- print("test_extract_sentences_thinking_tags: PASS")
31
-
32
-
33
- def test_extract_sentences_questions_exclamations():
34
- """Test mixed punctuation handling."""
35
- from core.text_gen import extract_sentences
36
- raw = "1. Hello.\n2. How are you?\n3. What a day!"
37
- result = extract_sentences(raw)
38
- assert len(result) == 3
39
- assert result[1] == "How are you?"
40
- assert result[2] == "What a day!"
41
- print("test_extract_sentences_questions_exclamations: PASS")
42
-
43
-
44
- def test_extract_sentences_zero_raises():
45
- """Test ValidationError when no numbered sentences found."""
46
- from core.text_gen import extract_sentences, ValidationError
47
- try:
48
- extract_sentences("No numbered lines here.")
49
- assert False, "Should raise"
50
- except ValidationError:
51
- pass
52
- print("test_extract_sentences_zero_raises: PASS")
53
-
54
-
55
- def test_extract_sentences_all_returned():
56
- """Test that all numbered sentences are returned (no truncation)."""
57
- from core.text_gen import extract_sentences
58
- result = extract_sentences("1. A.\n2. B.\n3. C.")
59
- assert len(result) == 3
60
- assert result == ["A.", "B.", "C."]
61
- print("test_extract_sentences_all_returned: PASS")
62
-
63
-
64
- def test_generate_sentences_mock():
65
- """Test generate_sentences with mocked LLM."""
66
- from unittest.mock import MagicMock
67
- from core.text_gen import generate_sentences
68
- from core.types import CEFRLevel
69
-
70
- mock_llm = MagicMock()
71
- mock_llm.create_chat_completion.return_value = {
72
- "choices": [{"message": {"content": "1. Hello.\n2. World."}}]
73
- }
74
-
75
- result = generate_sentences(
76
- scenario="test",
77
- cefr_level=CEFRLevel.A1,
78
- batch_size=2,
79
- llm=mock_llm,
80
- )
81
- assert len(result) == 2
82
- assert result[0] == "Hello."
83
- print("test_generate_sentences_mock: PASS")
84
-
85
-
86
- if __name__ == "__main__":
87
- test_extract_sentences_basic()
88
- test_extract_sentences_thinking_tags()
89
- test_extract_sentences_questions_exclamations()
90
- test_extract_sentences_zero_raises()
91
- test_extract_sentences_all_returned()
92
- test_generate_sentences_mock()
93
- print("\nAll inline tests passed.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/{translation_retry_test.py โ†’ engine_test.py} RENAMED
@@ -1,47 +1,129 @@
1
- """Quick inline test for LlamaCppTextEngine retry loop.
2
 
3
- Tests per-sentence translation with chat completion, retry on invalid
4
- output, and fallback โ€” without requiring a running model. Uses mock LLM.
5
  """
6
 
7
- import sys
8
- from pathlib import Path
9
 
10
- sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
 
11
 
12
- from unittest.mock import MagicMock, patch
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
- def test_translate_single_success():
16
- """Test that a valid translation is returned on first attempt."""
17
- from core.types import CEFRLevel
18
- from core.engine import LlamaCppTextEngine
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  mock_llm = MagicMock()
21
- mock_llm.create_chat_completion.return_value = {
22
- "choices": [{"message": {"content": "Sveiki."}}]
23
- }
24
 
25
  with patch.object(LlamaCppTextEngine, "_load_model"):
26
  engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
27
  engine._llm = mock_llm
28
  engine._loaded = True
 
29
 
30
  result = engine._translate_single("Hello.", CEFRLevel.A1)
31
 
32
  assert result == "Sveiki."
33
  assert mock_llm.create_chat_completion.call_count == 1
34
- print("test_translate_single_success: PASS")
35
 
36
 
37
  def test_translate_single_retry_on_invalid():
38
- """Test retry when first output is invalid (contains English word)."""
39
- from core.types import CEFRLevel
40
- from core.engine import LlamaCppTextEngine
41
-
42
  mock_llm = MagicMock()
43
- # First call returns invalid (contains "English" โ€” rejected by _is_valid_translation)
44
- # Second call returns valid translation
45
  mock_llm.create_chat_completion.side_effect = [
46
  {"choices": [{"message": {"content": "This is the English text"}}]},
47
  {"choices": [{"message": {"content": "Paldies."}}]},
@@ -51,94 +133,56 @@ def test_translate_single_retry_on_invalid():
51
  engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
52
  engine._llm = mock_llm
53
  engine._loaded = True
 
54
 
55
  result = engine._translate_single("Thank you.", CEFRLevel.A1)
56
 
57
  assert result == "Paldies."
58
  assert mock_llm.create_chat_completion.call_count == 2
59
- print("test_translate_single_retry_on_invalid: PASS")
60
 
61
 
62
  def test_translate_single_exhausted_retries_fallback():
63
- """Test that exhausted retries fall back to original English text."""
64
- from core.types import CEFRLevel
65
- from core.engine import LlamaCppTextEngine
66
-
67
  mock_llm = MagicMock()
68
- # All 3 attempts return empty strings (invalid)
69
- mock_llm.create_chat_completion.return_value = {
70
- "choices": [{"message": {"content": ""}}]
71
- }
72
 
73
  with patch.object(LlamaCppTextEngine, "_load_model"):
74
  engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
75
  engine._llm = mock_llm
76
  engine._loaded = True
 
77
 
78
  result = engine._translate_single("Hello.", CEFRLevel.A1)
79
 
80
  assert result == "Hello." # fallback to original English
81
  assert mock_llm.create_chat_completion.call_count == 3
82
- print("test_translate_single_exhausted_retries_fallback: PASS")
83
 
84
 
85
  def test_translate_single_multiline_rejected():
86
- """Test that multiline output is rejected (model generated too much)."""
87
- from core.types import CEFRLevel
88
- from core.engine import LlamaCppTextEngine
89
-
90
  mock_llm = MagicMock()
91
- # First call returns multiline โ€” rejected
92
- # Second call returns single valid line
93
  mock_llm.create_chat_completion.side_effect = [
94
- {"choices": [{"message": {"content": "Sveiki.\nKฤ tu esi?"}}]},
95
- {"choices": [{"message": {"content": "Sveiki."}}]},
96
  ]
97
 
98
  with patch.object(LlamaCppTextEngine, "_load_model"):
99
  engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
100
  engine._llm = mock_llm
101
  engine._loaded = True
 
102
 
103
  result = engine._translate_single("Hello.", CEFRLevel.A1)
104
 
105
- assert result == "Sveiki."
106
  assert mock_llm.create_chat_completion.call_count == 2
107
- print("test_translate_single_multiline_rejected: PASS")
108
 
109
 
110
- def test_is_valid_translation():
111
- """Test the _is_valid_translation helper for various inputs."""
112
- from core.engine import LlamaCppTextEngine
113
-
114
- with patch.object(LlamaCppTextEngine, "_load_model"):
115
- engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
116
- engine._loaded = True
117
-
118
- # Valid translations
119
- assert engine._is_valid_translation("Sveiki.") is True
120
- assert engine._is_valid_translation("Labrฤซt!") is True
121
- assert engine._is_valid_translation("Paldies, ka jautฤji.") is True
122
-
123
- # Invalid: empty
124
- assert engine._is_valid_translation("") is False
125
- assert engine._is_valid_translation(" ") is False
126
-
127
- # Invalid: contains English words (model echoed back)
128
- assert engine._is_valid_translation("This is the translation") is False
129
- assert engine._is_valid_translation("Translate this sentence") is False
130
-
131
- # Invalid: multiline
132
- assert engine._is_valid_translation("Line1\nLine2") is False
133
-
134
- print("test_is_valid_translation: PASS")
135
-
136
 
137
  def test_generate_calls_per_sentence():
138
- """Test that generate() calls _translate_single for each input text."""
139
- from core.types import CEFRLevel
140
- from core.engine import LlamaCppTextEngine
141
-
142
  mock_llm = MagicMock()
143
  responses = [
144
  {"choices": [{"message": {"content": "Sveiki."}}]},
@@ -151,6 +195,7 @@ def test_generate_calls_per_sentence():
151
  engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
152
  engine._llm = mock_llm
153
  engine._loaded = True
 
154
 
155
  result = engine.generate(
156
  texts=["Hello.", "How are you?", "Thank you."],
@@ -164,14 +209,53 @@ def test_generate_calls_per_sentence():
164
  assert result.generated_texts[1] == "Kฤ tu esi?"
165
  assert result.generated_texts[2] == "Paldies."
166
  assert mock_llm.create_chat_completion.call_count == 3
167
- print("test_generate_calls_per_sentence: PASS")
168
 
169
 
170
- if __name__ == "__main__":
171
- test_translate_single_success()
172
- test_translate_single_retry_on_invalid()
173
- test_translate_single_exhausted_retries_fallback()
174
- test_translate_single_multiline_rejected()
175
- test_is_valid_translation()
176
- test_generate_calls_per_sentence()
177
- print("\nAll inline tests passed.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for core.engine engines: MiniCPMTextEngine, LlamaCppTextEngine, EnginePool.
2
 
3
+ Merged from translation_retry_test.py. All tests mock the LLM โ€” no model inference.
 
4
  """
5
 
6
+ import pytest
7
+ from unittest.mock import patch, MagicMock
8
 
9
+ from core.types import CEFRLevel, TextResult, ValidationError
10
+ from core.engine import MiniCPMTextEngine, LlamaCppTextEngine, EnginePool
11
 
 
12
 
13
+ # โ”€โ”€ MiniCPMTextEngine โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
14
+
15
+ def test_minicpm_generate_calls_llm(mock_llm_response_factory):
16
+ """generate() calls llm.create_chat_completion and wraps in TextResult."""
17
+ mock_llm = MagicMock()
18
+ mock_llm.create_chat_completion.return_value = mock_llm_response_factory(
19
+ "1. Hello.\n2. World."
20
+ )
21
+
22
+ with patch.object(MiniCPMTextEngine, "_load_model"):
23
+ engine = MiniCPMTextEngine.__new__(MiniCPMTextEngine)
24
+ engine._llm = mock_llm
25
+ engine._loaded = True
26
+
27
+ result = engine.generate(
28
+ texts=[], # empty = generation mode (not translation)
29
+ scenario="test",
30
+ cefr_level=CEFRLevel.A1,
31
+ batch_size=2,
32
+ )
33
+
34
+ assert isinstance(result, TextResult)
35
+ assert len(result.generated_texts) == 2
36
+ assert result.generated_texts[0] == "Hello."
37
+ mock_llm.create_chat_completion.assert_called_once()
38
+
39
+
40
+ def test_minicpm_generate_propagates_validation_error():
41
+ """ValidationError from text_gen propagate through generate()."""
42
+ from core.text_gen import ValidationError as TextGenValidationError
43
+
44
+ mock_llm = MagicMock()
45
+ # LLM returns content that yields 0 sentences after parsing
46
+ mock_llm.create_chat_completion.return_value = {"choices": [{"message": {"content": "no numbers here"}}]}
47
+
48
+ with patch.object(MiniCPMTextEngine, "_load_model"):
49
+ engine = MiniCPMTextEngine.__new__(MiniCPMTextEngine)
50
+ engine._llm = mock_llm
51
+ engine._loaded = True
52
+
53
+ # Should raise ValidationError after retries exhausted
54
+ with pytest.raises((ValidationError, TextGenValidationError)):
55
+ engine.generate(
56
+ texts=[],
57
+ scenario="test",
58
+ cefr_level=CEFRLevel.A1,
59
+ batch_size=2,
60
+ )
61
+
62
+
63
+ # โ”€โ”€ LlamaCppTextEngine._is_valid_translation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
64
+
65
+ def test_is_valid_translation_valid():
66
+ """Valid: non-empty single line, no English words."""
67
+ with patch.object(LlamaCppTextEngine, "_load_model"):
68
+ engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
69
+ engine._loaded = True
70
+
71
+ assert engine._is_valid_translation("Sveiki.") is True
72
+ assert engine._is_valid_translation("Labrฤซt!") is True
73
+ assert engine._is_valid_translation("Paldies, ka jautฤji.") is True
74
+
75
+
76
+ def test_is_valid_translation_invalid_empty():
77
+ """Invalid: empty string or whitespace-only."""
78
+ with patch.object(LlamaCppTextEngine, "_load_model"):
79
+ engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
80
+ engine._loaded = True
81
+
82
+ assert engine._is_valid_translation("") is False
83
+ assert engine._is_valid_translation(" ") is False
84
+
85
+
86
+ def test_is_valid_translation_invalid_english_words():
87
+ """Invalid: contains English words (model echoed back)."""
88
+ with patch.object(LlamaCppTextEngine, "_load_model"):
89
+ engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
90
+ engine._loaded = True
91
+
92
+ assert engine._is_valid_translation("This is the translation") is False
93
+ assert engine._is_valid_translation("Translate this sentence") is False
94
 
 
 
 
 
95
 
96
+ def test_is_valid_translation_invalid_multiline():
97
+ """Invalid: multiline output."""
98
+ with patch.object(LlamaCppTextEngine, "_load_model"):
99
+ engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
100
+ engine._loaded = True
101
+
102
+ assert engine._is_valid_translation("Line1\nLine2") is False
103
+
104
+
105
+ # โ”€โ”€ LlamaCppTextEngine._translate_single โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
106
+
107
+ def test_translate_single_success():
108
+ """Valid translation returned on first attempt."""
109
  mock_llm = MagicMock()
110
+ mock_llm.create_chat_completion.return_value = {"choices": [{"message": {"content": "Sveiki."}}]}
 
 
111
 
112
  with patch.object(LlamaCppTextEngine, "_load_model"):
113
  engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
114
  engine._llm = mock_llm
115
  engine._loaded = True
116
+ engine.target_language = "Latvian"
117
 
118
  result = engine._translate_single("Hello.", CEFRLevel.A1)
119
 
120
  assert result == "Sveiki."
121
  assert mock_llm.create_chat_completion.call_count == 1
 
122
 
123
 
124
  def test_translate_single_retry_on_invalid():
125
+ """Retry when first output invalid (contains English word), second succeeds."""
 
 
 
126
  mock_llm = MagicMock()
 
 
127
  mock_llm.create_chat_completion.side_effect = [
128
  {"choices": [{"message": {"content": "This is the English text"}}]},
129
  {"choices": [{"message": {"content": "Paldies."}}]},
 
133
  engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
134
  engine._llm = mock_llm
135
  engine._loaded = True
136
+ engine.target_language = "Latvian"
137
 
138
  result = engine._translate_single("Thank you.", CEFRLevel.A1)
139
 
140
  assert result == "Paldies."
141
  assert mock_llm.create_chat_completion.call_count == 2
 
142
 
143
 
144
  def test_translate_single_exhausted_retries_fallback():
145
+ """Exhausted retries โ†’ fallback to original English text."""
 
 
 
146
  mock_llm = MagicMock()
147
+ # All 3 attempts return invalid output (empty string)
148
+ mock_llm.create_chat_completion.return_value = {"choices": [{"message": {"content": ""}}]}
 
 
149
 
150
  with patch.object(LlamaCppTextEngine, "_load_model"):
151
  engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
152
  engine._llm = mock_llm
153
  engine._loaded = True
154
+ engine.target_language = "Latvian"
155
 
156
  result = engine._translate_single("Hello.", CEFRLevel.A1)
157
 
158
  assert result == "Hello." # fallback to original English
159
  assert mock_llm.create_chat_completion.call_count == 3
 
160
 
161
 
162
  def test_translate_single_multiline_rejected():
163
+ """Multiline output rejected, triggers retry."""
 
 
 
164
  mock_llm = MagicMock()
 
 
165
  mock_llm.create_chat_completion.side_effect = [
166
+ {"choices": [{"message": {"content": "Line1\nLine2"}}]}, # invalid: multiline
167
+ {"choices": [{"message": {"content": "Paldies."}}]}, # valid (no English words)
168
  ]
169
 
170
  with patch.object(LlamaCppTextEngine, "_load_model"):
171
  engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
172
  engine._llm = mock_llm
173
  engine._loaded = True
174
+ engine.target_language = "Latvian"
175
 
176
  result = engine._translate_single("Hello.", CEFRLevel.A1)
177
 
178
+ assert result == "Paldies."
179
  assert mock_llm.create_chat_completion.call_count == 2
 
180
 
181
 
182
+ # โ”€โ”€ LlamaCppTextEngine.generate โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
 
184
  def test_generate_calls_per_sentence():
185
+ """generate() calls _translate_single for each input text."""
 
 
 
186
  mock_llm = MagicMock()
187
  responses = [
188
  {"choices": [{"message": {"content": "Sveiki."}}]},
 
195
  engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
196
  engine._llm = mock_llm
197
  engine._loaded = True
198
+ engine.target_language = "Latvian"
199
 
200
  result = engine.generate(
201
  texts=["Hello.", "How are you?", "Thank you."],
 
209
  assert result.generated_texts[1] == "Kฤ tu esi?"
210
  assert result.generated_texts[2] == "Paldies."
211
  assert mock_llm.create_chat_completion.call_count == 3
 
212
 
213
 
214
+ # โ”€โ”€ EnginePool โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
215
+
216
+ def test_engine_pool_get_creates_singleton():
217
+ """First get() creates a new EnginePool instance."""
218
+ from core.types import EngineConfig
219
+
220
+ config = MagicMock(spec=EngineConfig)
221
+ config.batch_size = 3
222
+ config.target_language = "Latvian"
223
+ config.device = "cpu"
224
+ pool = EnginePool.get(config)
225
+ assert isinstance(pool, EnginePool)
226
+ EnginePool.reset()
227
+
228
+
229
+ def test_engine_pool_get_returns_same_instance():
230
+ """Second get() returns the same instance."""
231
+ from core.types import EngineConfig
232
+
233
+ config = MagicMock(spec=EngineConfig)
234
+ config.batch_size = 3
235
+ config.target_language = "Latvian"
236
+ config.device = "cpu"
237
+ pool1 = EnginePool.get(config)
238
+ pool2 = EnginePool.get(config)
239
+ assert pool1 is pool2
240
+ EnginePool.reset()
241
+
242
+
243
+ def test_engine_pool_reset_clears_singleton():
244
+ """reset() clears singleton and unloads engines."""
245
+ from core.types import EngineConfig
246
+
247
+ config = MagicMock(spec=EngineConfig)
248
+ config.batch_size = 3
249
+ config.target_language = "Latvian"
250
+ config.device = "cpu"
251
+ pool1 = EnginePool.get(config)
252
+
253
+ # Create a second reference
254
+ pool2 = EnginePool.get(config)
255
+ assert pool1 is pool2
256
+
257
+ EnginePool.reset()
258
+
259
+ # After reset, new get() should return a different instance
260
+ pool3 = EnginePool.get(config)
261
+ assert pool3 is not pool1
tests/image_gen_test.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for core.image_gen.ImageGenEngine."""
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import patch, MagicMock
5
+ import pytest
6
+
7
+ from core.image_gen import ImageGenEngine
8
+ from core.types import ImageResult
9
+
10
+
11
+ def test_imagegen_engine_generate_success(mock_image_paths, temp_output_dir):
12
+ """Success path: mock pipeline returns images โ†’ .png file written, path in result."""
13
+ engine = ImageGenEngine(device="cpu")
14
+
15
+ mock_pipeline = MagicMock()
16
+ # Simulate pipeline returning a list with one PIL-like image object
17
+ mock_image = MagicMock()
18
+ mock_image.size = (512, 512)
19
+
20
+ def save_side_effect(path):
21
+ # Create the actual file so Path.exists() assertions pass
22
+ Path(path).touch()
23
+
24
+ mock_image.save = MagicMock(side_effect=save_side_effect)
25
+
26
+ mock_result = MagicMock()
27
+ mock_result.images = [mock_image]
28
+ mock_pipeline.return_value = mock_result
29
+
30
+ with patch.object(engine, "_load_pipeline"):
31
+ engine._pipeline = mock_pipeline
32
+ engine._loaded = True
33
+
34
+ result = engine.generate(
35
+ prompts=["A cat.", "A dog."],
36
+ output_dir=temp_output_dir,
37
+ )
38
+
39
+ assert isinstance(result, ImageResult)
40
+ assert len(result.image_paths) == 2
41
+ assert result.image_paths[0] is not None
42
+ assert result.image_paths[1] is not None
43
+ assert Path(result.image_paths[0]).exists()
44
+ assert Path(result.image_paths[1]).exists()
45
+
46
+
47
+ def test_imagegen_engine_generate_failure_path(temp_output_dir):
48
+ """Failure path: mock pipeline raises exception โ†’ None in result list."""
49
+ engine = ImageGenEngine(device="cpu")
50
+
51
+ mock_pipeline = MagicMock()
52
+ mock_pipeline.side_effect = RuntimeError("OOM")
53
+
54
+ with patch.object(engine, "_load_pipeline"):
55
+ engine._pipeline = mock_pipeline
56
+ engine._loaded = True
57
+
58
+ result = engine.generate(
59
+ prompts=["A cat.", "A dog."],
60
+ output_dir=temp_output_dir,
61
+ )
62
+
63
+ assert len(result.image_paths) == 2
64
+ assert result.image_paths[0] is None
65
+ assert result.image_paths[1] is None
66
+
67
+
68
+ def test_imagegen_engine_generate_empty_input(temp_output_dir):
69
+ """Empty input list: returns empty ImageResult."""
70
+ engine = ImageGenEngine(device="cpu")
71
+
72
+ with patch.object(engine, "_load_pipeline"):
73
+ result = engine.generate(
74
+ prompts=[],
75
+ output_dir=temp_output_dir,
76
+ )
77
+
78
+ assert isinstance(result, ImageResult)
79
+ assert result.image_paths == []
80
+
81
+
82
+ def test_imagegen_engine_generate_empty_output_warning(temp_output_dir):
83
+ """Pipeline returns empty list โ†’ None path logged."""
84
+ engine = ImageGenEngine(device="cpu")
85
+
86
+ mock_pipeline = MagicMock()
87
+ mock_result = MagicMock()
88
+ mock_result.images = [] # Empty output
89
+ mock_pipeline.return_value = mock_result
90
+
91
+ with patch.object(engine, "_load_pipeline"):
92
+ engine._pipeline = mock_pipeline
93
+ engine._loaded = True
94
+
95
+ result = engine.generate(
96
+ prompts=["A cat."],
97
+ output_dir=temp_output_dir,
98
+ )
99
+
100
+ assert len(result.image_paths) == 1
101
+ assert result.image_paths[0] is None
102
+
103
+
104
+ def test_imagegen_engine_unload():
105
+ """Pipeline deleted, _loaded reset to False, torch.cuda.empty_cache() called."""
106
+ engine = ImageGenEngine(device="cuda")
107
+
108
+ mock_pipeline = MagicMock()
109
+ engine._pipeline = mock_pipeline
110
+ engine._loaded = True
111
+
112
+ with patch("torch.cuda.empty_cache") as mock_empty:
113
+ engine.unload()
114
+
115
+ assert engine._pipeline is None
116
+ assert engine._loaded is False
117
+ mock_empty.assert_called_once()
118
+
119
+
120
+ def test_imagegen_engine_unload_already_unloaded():
121
+ """Calling unload when already unloaded does not error."""
122
+ engine = ImageGenEngine(device="cuda")
123
+ engine._pipeline = None
124
+ engine._loaded = False
125
+
126
+ engine.unload()
127
+ assert engine._loaded is False
tests/pipeline_test.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for core.pipeline.generate_phase2() orchestration.
2
+
3
+ Mocks EnginePool and individual engines. Verifies orchestration flow,
4
+ progress percentages, and CardData assembly.
5
+ """
6
+
7
+ import pytest
8
+ from unittest.mock import patch, MagicMock, PropertyMock
9
+ import inspect
10
+
11
+ from core.types import CEFRLevel
12
+
13
+
14
+ def test_generate_phase2_is_generator():
15
+ """generate_phase2 is a generator function."""
16
+ from core.pipeline import generate_phase2
17
+ assert inspect.isgeneratorfunction(generate_phase2)
18
+
19
+
20
+ def test_generate_phase2_translation_only(mock_english_texts, mock_spanish_translations):
21
+ """Translation-only: yields progress updates per sentence, final CardData list with translations."""
22
+ from core.pipeline import generate_phase2
23
+ from core.types import CardData
24
+
25
+ # Build mock engine that returns translated texts via _translate_single
26
+ mock_engine = MagicMock()
27
+ mock_engine._translate_single.side_effect = list(mock_spanish_translations)
28
+
29
+ mock_pool_instance = MagicMock()
30
+ mock_pool_instance.get_translation_engine.return_value = mock_engine
31
+
32
+ with patch("core.pipeline.EnginePool") as mock_pool_class:
33
+ mock_pool_class.get.return_value = mock_pool_instance
34
+ yields = list(generate_phase2(
35
+ texts=list(mock_english_texts),
36
+ scenario="test",
37
+ cefr_level=CEFRLevel.A1,
38
+ batch_size=3,
39
+ target_language="Spanish",
40
+ include_audio=False,
41
+ ))
42
+
43
+ # Should yield at least: progress prepare, progress per sentence, final complete
44
+ assert len(yields) >= 5 # 20% prepare + 3 translation steps + final
45
+
46
+ # Last yield should have CardData list
47
+ last_progress, last_label, last_result = yields[-1]
48
+ assert isinstance(last_result, list)
49
+ assert len(last_result) == 3
50
+ assert all(isinstance(card, CardData) for card in last_result)
51
+
52
+
53
+ def test_generate_phase2_translation_plus_tts(mock_english_texts, mock_spanish_translations):
54
+ """Translation+TTS: additional yield at 70% for audio generation, CardData includes audio_paths."""
55
+ from core.pipeline import generate_phase2
56
+ from core.types import AudioResult
57
+
58
+ mock_engine = MagicMock()
59
+ mock_engine._translate_single.side_effect = list(mock_spanish_translations)
60
+
61
+ mock_audio_result = MagicMock()
62
+ mock_audio_result.audio_paths = ["/tmp/audio_0.wav", "/tmp/audio_1.wav", "/tmp/audio_2.wav"]
63
+
64
+ mock_tts_engine = MagicMock()
65
+ mock_tts_engine.synthesize.return_value = mock_audio_result
66
+
67
+ mock_pool_instance = MagicMock()
68
+ mock_pool_instance.get_translation_engine.return_value = mock_engine
69
+ mock_pool_instance.get_tts_engine.return_value = mock_tts_engine
70
+
71
+ with patch("core.pipeline.EnginePool") as mock_pool_class:
72
+ mock_pool_class.get.return_value = mock_pool_instance
73
+ yields = list(generate_phase2(
74
+ texts=list(mock_english_texts),
75
+ scenario="test",
76
+ cefr_level=CEFRLevel.A1,
77
+ batch_size=3,
78
+ target_language="Spanish",
79
+ include_audio=True,
80
+ ))
81
+
82
+ # Check that audio generation progress was yielded (label is 2nd element)
83
+ progress_labels = [label for _, label, _ in yields]
84
+ assert any("audio" in str(label).lower() for label in progress_labels)
85
+
86
+ # Final CardData should have audio_paths
87
+ last_progress, last_label, last_result = yields[-1]
88
+ assert len(last_result) == 3
89
+ assert all(card.audio_path is not None for card in last_result)
90
+
91
+
92
+ def test_generate_phase2_progress_percentages():
93
+ """Progress percentages: 20% prepare, 15-70% translation steps, 100% complete."""
94
+ from core.pipeline import generate_phase2
95
+
96
+ mock_engine = MagicMock()
97
+ mock_engine._translate_single.side_effect = ["A.", "B."]
98
+
99
+ mock_pool_instance = MagicMock()
100
+ mock_pool_instance.get_translation_engine.return_value = mock_engine
101
+
102
+ with patch("core.pipeline.EnginePool") as mock_pool_class:
103
+ mock_pool_class.get.return_value = mock_pool_instance
104
+ yields = list(generate_phase2(
105
+ texts=["A.", "B."],
106
+ scenario="test",
107
+ cefr_level=CEFRLevel.A1,
108
+ batch_size=2,
109
+ target_language="Spanish",
110
+ include_audio=False,
111
+ ))
112
+
113
+ # First yield should be ~20% (prepare)
114
+ first_progress, _, _ = yields[0]
115
+ assert "20" in str(first_progress) or "Preparing" in str(first_progress).lower()
116
+
117
+ # Progress values increase during translation
118
+ progress_values = []
119
+ for p, _, _ in yields:
120
+ if isinstance(p, (int, float)):
121
+ progress_values.append(p)
122
+ if len(progress_values) >= 2:
123
+ assert progress_values[-1] >= progress_values[0]
124
+
125
+
126
+ def test_generate_phase2_validation_error_propagation():
127
+ """If translation fails after retries, ValidationError is raised and not caught."""
128
+ from core.pipeline import generate_phase2
129
+ from core.types import ValidationError
130
+
131
+ mock_engine = MagicMock()
132
+ # Simulate engine raising ValidationError
133
+ mock_engine._translate_single.side_effect = ValidationError("Translation failed", raw_output="bad output")
134
+
135
+ mock_pool_instance = MagicMock()
136
+ mock_pool_instance.get_translation_engine.return_value = mock_engine
137
+
138
+ with patch("core.pipeline.EnginePool") as mock_pool_class:
139
+ mock_pool_class.get.return_value = mock_pool_instance
140
+ with pytest.raises(ValidationError):
141
+ list(generate_phase2(
142
+ texts=["A."],
143
+ scenario="test",
144
+ cefr_level=CEFRLevel.A1,
145
+ batch_size=1,
146
+ target_language="Spanish",
147
+ include_audio=False,
148
+ ))
tests/progression_test.py DELETED
@@ -1,127 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Test per-sentence translation progression and progress calculation.
3
-
4
- Runs without any model โ€” tests the _progress_pct helper and verifies
5
- that generate_media_async yields progressively with growing card lists.
6
- """
7
-
8
- import sys
9
- import os
10
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11
-
12
- from app import _progress_pct
13
-
14
-
15
- def test_progress_pct():
16
- """Test progress percentage calculation for various batch sizes."""
17
- # Single sentence โ€” always 100%
18
- pct, label = _progress_pct(0, 1)
19
- assert pct == 100.0, f"Expected 100.0, got {pct}"
20
- assert "complete" in label.lower(), f"Expected 'complete' in label, got '{label}'"
21
-
22
- # Two sentences
23
- pct, label = _progress_pct(0, 2)
24
- assert abs(pct - 50.0) < 0.1, f"Expected ~50.0, got {pct}"
25
- assert "1/2" in label, f"Expected '1/2' in label, got '{label}'"
26
- assert "remaining" in label.lower(), f"Expected 'remaining' in label, got '{label}'"
27
-
28
- pct, label = _progress_pct(1, 2)
29
- assert pct == 100.0, f"Expected 100.0, got {pct}"
30
- assert "complete" in label.lower(), f"Expected 'complete' in label, got '{label}'"
31
-
32
- # Five sentences โ€” check multiple steps
33
- for i in range(5):
34
- pct, label = _progress_pct(i, 5)
35
- expected_pct = round(((i + 1) / 5) * 100, 1)
36
- assert abs(pct - expected_pct) < 0.1, f"Step {i}: expected ~{expected_pct}, got {pct}"
37
- remaining = 5 - (i + 1)
38
- if pct < 100:
39
- assert str(remaining) in label, f"Step {i}: expected '{remaining}' in label, got '{label}'"
40
- else:
41
- assert "complete" in label.lower(), f"Step {i}: expected 'complete' in label, got '{label}'"
42
-
43
- print("โœ“ _progress_pct tests passed")
44
-
45
-
46
- def test_generate_media_async_yields():
47
- """Verify generate_media_async yields progressively with growing card lists.
48
-
49
- Uses mock data โ€” no model inference needed.
50
- """
51
- from app import generate_media_async, _phase1_texts
52
-
53
- # Save original state
54
- original_texts = list(_phase1_texts)
55
-
56
- try:
57
- # Set up mock Phase 1 texts
58
- _phase1_texts.clear()
59
- test_texts = [
60
- "The cat sits on the mat.",
61
- "A family has many people.",
62
- "Children play together.",
63
- ]
64
- _phase1_texts.extend(test_texts)
65
-
66
- # Collect all yields from generate_media_async
67
- # We can't actually run it without a model, so we just verify
68
- # the function signature and docstring are correct
69
- import inspect
70
- sig = inspect.signature(generate_media_async)
71
- params = list(sig.parameters.keys())
72
- assert "scenario" in params, "Missing 'scenario' parameter"
73
- assert "cefr_level" in params, "Missing 'cefr_level' parameter"
74
- assert "batch_size" in params, "Missing 'batch_size' parameter"
75
-
76
- # Verify it's a generator function
77
- import types
78
- assert isinstance(generate_media_async, types.GeneratorType) or \
79
- inspect.isgeneratorfunction(generate_media_async), \
80
- "generate_media_async should be a generator function"
81
-
82
- print("โœ“ generate_media_async structure verified")
83
- finally:
84
- # Restore original state
85
- _phase1_texts.clear()
86
- _phase1_texts.extend(original_texts)
87
-
88
-
89
- def test_card_data_progression():
90
- """Simulate what cards look like after each translation step."""
91
- from frontend.ui.cards import generate_cards_html
92
-
93
- # Simulate progressive card building (mock translations)
94
- english_sentences = [
95
- "The cat sits on the mat.",
96
- "A family has many people.",
97
- "Children play together.",
98
- ]
99
- mock_translations = [
100
- "Kaฤทis sฤ“ลพ uz paklฤja.",
101
- "ฤขimenei ir daudz cilvฤ“ku.",
102
- "Bฤ“rni spฤ“lฤ“ kopฤ.",
103
- ]
104
-
105
- for i in range(len(english_sentences)):
106
- cards = []
107
- for j in range(i + 1):
108
- cards.append({
109
- "text": english_sentences[j],
110
- "translation": mock_translations[j],
111
- "cefr_level": "B1",
112
- })
113
-
114
- html = generate_cards_html(cards, include_image=False, include_audio=False, placeholder_back=False)
115
-
116
- # Verify each card's translation is present in the HTML
117
- for j in range(i + 1):
118
- assert mock_translations[j] in html, f"Translation {j} not found after step {i}"
119
-
120
- print(f"โœ“ Step {i+1}/{len(english_sentences)}: {len(cards)} card(s) rendered correctly")
121
-
122
-
123
- if __name__ == "__main__":
124
- test_progress_pct()
125
- test_generate_media_async_yields()
126
- test_card_data_progression()
127
- print("\nโœ… All progression tests passed!")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/smoke_test.py CHANGED
@@ -1,105 +1,121 @@
1
- # EuropaLex Smoke Test
2
- # End-to-end pipeline test with mock models (no actual inference)
3
-
4
- import sys
5
-
6
-
7
- def main():
8
- errors = []
9
-
10
- # Test 1: Import core types
11
- try:
12
- from core.types import CardData, CEFRLevel, TextResult, AudioResult, ImageResult, EngineConfig
13
- print("โœ“ core.types imports OK")
14
- except Exception as e:
15
- errors.append(f"core.types import failed: {e}")
16
-
17
- # Test 2: Import engine modules
18
- try:
19
- from core.engine import MiniCPMTextEngine, LlamaCppTextEngine, TTSEngine, ImageGenEngine, EnginePool
20
- print("โœ“ core.engine imports OK")
21
- except Exception as e:
22
- errors.append(f"core.engine import failed: {e}")
23
-
24
- # Test 3: Validate CardData construction
25
- try:
26
- card = CardData(text="Hello", translation="Sveiki", cefr_level=CEFRLevel.A1)
27
- assert card.text == "Hello"
28
- assert card.translation == "Sveiki"
29
- assert card.cefr_level == CEFRLevel.A1
30
- print("โœ“ CardData validation OK")
31
- except Exception as e:
32
- errors.append(f"CardData validation failed: {e}")
33
-
34
- # Test 4: Validate TextResult construction
35
- try:
36
- result = TextResult(generated_texts=["Sveiki", "Labdien"])
37
- assert len(result.generated_texts) == 2
38
- print("โœ“ TextResult validation OK")
39
- except Exception as e:
40
- errors.append(f"TextResult validation failed: {e}")
41
-
42
- # Test 5: Validate AudioResult construction (never None โ€” default_factory=list)
43
- try:
44
- result = AudioResult(audio_paths=["/path/audio_0.wav", "/path/audio_1.wav"])
45
- assert len(result.audio_paths) == 2
46
- print("โœ“ AudioResult validation OK")
47
- except Exception as e:
48
- errors.append(f"AudioResult validation failed: {e}")
49
-
50
- # Test 6: Validate ImageResult construction (never None โ€” default_factory=list)
51
- try:
52
- result = ImageResult(image_paths=["/path/image_0.png"])
53
- assert len(result.image_paths) == 1
54
- print("โœ“ ImageResult validation OK")
55
- except Exception as e:
56
- errors.append(f"ImageResult validation failed: {e}")
57
-
58
- # Test 7: Validate TextResult.validate_and_parse gate
59
- try:
60
- from core.types import ValidationError
61
- result = TextResult.validate_and_parse("Hello\nWorld", expected_count=2)
62
- assert len(result.generated_texts) == 2
63
- assert result.generated_texts[0] == "Hello"
64
- # Strips thinking tags
65
- raw_with_tags = "<thinking>reasoning here</thinking>\nFoo\nBar"
66
- result2 = TextResult.validate_and_parse(raw_with_tags, expected_count=2)
67
- assert len(result2.generated_texts) == 2
68
- assert result2.generated_texts[0] == "Foo"
69
- # Raises on count mismatch
70
- try:
71
- TextResult.validate_and_parse("One\nTwo\nThree", expected_count=2)
72
- assert False, "Should have raised ValidationError"
73
- except ValidationError:
74
- pass # expected
75
- print("โœ“ TextResult.validate_and_parse gate OK")
76
- except Exception as e:
77
- errors.append(f"TextResult validation gate failed: {e}")
78
-
79
- # Test 8: Import frontend modules
80
- try:
81
- from frontend.ui.cards import render_card_html, generate_cards_html, generate_progress_html
82
- from frontend.ui.widgets import create_toggle
83
- print("โœ“ frontend.ui imports OK")
84
- except Exception as e:
85
- errors.append(f"frontend.ui import failed: {e}")
86
-
87
- # Test 9: Import app module
88
- try:
89
- import app
90
- print("โœ“ app module loads OK")
91
- except Exception as e:
92
- errors.append(f"app module load failed: {e}")
93
-
94
- if errors:
95
- print(f"\nโŒ {len(errors)} error(s):")
96
- for e in errors:
97
- print(f" - {e}")
98
- sys.exit(1)
99
- else:
100
- print("\nโœ… All smoke tests passed!")
101
- sys.exit(0)
102
-
103
-
104
- if __name__ == "__main__":
105
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pytest rewrite of EuropaLex smoke test.
2
+
3
+ Validates: all modules import, Pydantic model construction,
4
+ TextResult.validate_and_parse() gate behavior.
5
+ """
6
+
7
+ import pytest
8
+
9
+
10
+ def test_all_modules_import():
11
+ """All project modules can be imported without error."""
12
+ import core.types # noqa: F401
13
+ import core.text_gen # noqa: F401
14
+ import core.engine # noqa: F401
15
+ import core.audio_gen # noqa: F401
16
+ import core.image_gen # noqa: F401
17
+ import frontend.ui.cards # noqa: F401
18
+ import frontend.ui.widgets # noqa: F401
19
+
20
+
21
+ def test_carddata_construction():
22
+ """CardData Pydantic model constructs with all fields."""
23
+ from core.types import CardData
24
+
25
+ card = CardData(text="Hello", translation="Sveiki")
26
+ assert card.text == "Hello"
27
+ assert card.translation == "Sveiki"
28
+ assert card.audio_path is None
29
+ assert card.image_path is None
30
+
31
+
32
+ def test_textresult_construction():
33
+ """TextResult constructs with generated_texts list."""
34
+ from core.types import TextResult
35
+
36
+ result = TextResult(generated_texts=["A.", "B."])
37
+ assert len(result.generated_texts) == 2
38
+
39
+
40
+ def test_audioreresult_construction():
41
+ """AudioResult defaults to empty list."""
42
+ from core.types import AudioResult
43
+
44
+ result = AudioResult()
45
+ assert result.audio_paths == []
46
+
47
+
48
+ def test_imageresult_construction():
49
+ """ImageResult defaults to empty list."""
50
+ from core.types import ImageResult
51
+
52
+ result = ImageResult()
53
+ assert result.image_paths == []
54
+
55
+
56
+ def test_engineconfig_from_settings():
57
+ """EngineConfig loads from settings.yaml (uses default paths, no model check)."""
58
+ from core.types import EngineConfig
59
+
60
+ config = EngineConfig.from_settings_yaml()
61
+ assert config.batch_size > 0
62
+ assert config.device in ("cuda", "mps", "cpu")
63
+
64
+
65
+ def test_cefrlevel_enum():
66
+ """CEFRLevel enum has all expected values and label/description methods."""
67
+ from core.types import CEFRLevel
68
+
69
+ levels = [CEFRLevel.A1, CEFRLevel.A2, CEFRLevel.B1, CEFRLevel.B2, CEFRLevel.C1, CEFRLevel.C2]
70
+ for level in levels:
71
+ assert isinstance(level.label(), str)
72
+ assert len(level.label()) > 0
73
+ assert isinstance(level.description(), str)
74
+ assert len(level.description()) > 0
75
+
76
+
77
+ def test_validationerror_structure():
78
+ """ValidationError carries raw_output attribute."""
79
+ from core.types import ValidationError
80
+
81
+ err = ValidationError("test message", raw_output="raw llm output")
82
+ assert err.raw_output == "raw llm output"
83
+ assert str(err) == "test message"
84
+
85
+
86
+ def test_textresult_validate_and_parse_strips_thinking_tags():
87
+ """validate_and_parse strips <thinking> tags before splitting lines."""
88
+ from core.types import TextResult
89
+
90
+ raw = "<thinking>reasoning</thinking>\nHello.\nWorld."
91
+ result = TextResult.validate_and_parse(raw, expected_count=2)
92
+ assert result.generated_texts == ["Hello.", "World."]
93
+
94
+
95
+ def test_textresult_validate_and_parse_enforces_count():
96
+ """validate_and_parse raises ValidationError when count mismatches."""
97
+ from core.types import TextResult, ValidationError
98
+
99
+ raw = "Line one.\nLine two."
100
+ with pytest.raises(ValidationError) as exc_info:
101
+ TextResult.validate_and_parse(raw, expected_count=5)
102
+ assert "Expected 5 sentences but got 2" in str(exc_info.value)
103
+ assert exc_info.value.raw_output == raw
104
+
105
+
106
+ def test_textresult_validate_and_parse_empty_raises():
107
+ """validate_and_parse raises ValidationError on empty output after tag stripping."""
108
+ from core.types import TextResult, ValidationError
109
+
110
+ raw = "<thinking>only reasoning</thinking>"
111
+ with pytest.raises(ValidationError):
112
+ TextResult.validate_and_parse(raw, expected_count=1)
113
+
114
+
115
+ def test_textresult_validate_and_parse_no_expected_count():
116
+ """validate_and_parse returns all lines when expected_count is None."""
117
+ from core.types import TextResult
118
+
119
+ raw = "A.\nB.\nC."
120
+ result = TextResult.validate_and_parse(raw, expected_count=None)
121
+ assert len(result.generated_texts) == 3
tests/{extract_sentences_test.py โ†’ text_gen_test.py} RENAMED
@@ -1,24 +1,28 @@
1
- """Tests for core.text_gen.extract_sentences โ€” pure function, no LLM needed."""
2
 
3
- import sys
4
- from pathlib import Path
 
5
 
6
- # Ensure project root is on path
7
- sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
8
 
 
 
9
 
10
- def test_exact_count():
11
- """Extract all numbered sentences โ€” no cap."""
12
- from core.text_gen import extract_sentences
 
 
13
  result = extract_sentences("1. Hello world.\n2. Goodbye world.")
14
  assert len(result) == 2
15
  assert result[0] == "Hello world."
16
  assert result[1] == "Goodbye world."
17
 
18
 
19
- def test_thinking_tag_stripping():
20
- """Strip <thinking> tags before parsing."""
21
- from core.text_gen import extract_sentences
22
  raw = "<thinking>some thoughts\nmore thoughts</thinking>\n1. Sentence one.\n2. Sentence two."
23
  result = extract_sentences(raw)
24
  assert len(result) == 2
@@ -26,9 +30,8 @@ def test_thinking_tag_stripping():
26
  assert result[1] == "Sentence two."
27
 
28
 
29
- def test_questions_and_exclamations():
30
- """Handle sentences ending with ? and ! as valid."""
31
- from core.text_gen import extract_sentences
32
  raw = "1. Hello.\n2. How are you?\n3. What a day!"
33
  result = extract_sentences(raw)
34
  assert len(result) == 3
@@ -37,40 +40,23 @@ def test_questions_and_exclamations():
37
  assert result[2] == "What a day!"
38
 
39
 
40
- def test_mixed_punctuation_with_thinking():
41
- """Strip tags and handle mixed . ? ! endings."""
42
- from core.text_gen import extract_sentences
43
- raw = "<thinking>reasoning here</thinking>\n1. The cat sits.\n2. Is it hungry?\n3. It wants food!"
44
- result = extract_sentences(raw)
45
- assert len(result) == 3
46
- assert result[0] == "The cat sits."
47
- assert result[1] == "Is it hungry?"
48
- assert result[2] == "It wants food!"
49
-
50
-
51
- def test_too_few_raises_validationerror():
52
- """Raise ValidationError if zero numbered sentences found."""
53
- from core.text_gen import extract_sentences, ValidationError
54
- try:
55
  extract_sentences("No numbered lines here.\nJust plain text.")
56
- assert False, "Should raise ValidationError"
57
- except ValidationError:
58
- pass
59
 
60
 
61
- def test_all_kept_no_truncation():
62
- """All numbered sentences are kept โ€” no upper cap."""
63
- from core.text_gen import extract_sentences
64
- result = extract_sentences("1. First.\n2. Second.\n3. Third.")
65
- assert len(result) == 3
66
- assert result[0] == "First."
67
- assert result[1] == "Second."
68
- assert result[2] == "Third."
69
 
70
 
71
- def test_ignores_non_numbered_lines():
72
- """Non-numbered lines are silently ignored, not discarded."""
73
- from core.text_gen import extract_sentences
74
  raw = "Some intro text.\n1. Valid sentence.\nMore text.\n2. Another valid."
75
  result = extract_sentences(raw)
76
  assert len(result) == 2
@@ -78,68 +64,50 @@ def test_ignores_non_numbered_lines():
78
  assert result[1] == "Another valid."
79
 
80
 
81
- def test_empty_after_tag_stripping_raises():
82
- """Raise if raw text contains only thinking tags."""
83
- from core.text_gen import extract_sentences, ValidationError
84
- try:
85
- extract_sentences("<thinking>only reasoning</thinking>")
86
- assert False, "Should raise on empty output"
87
- except ValidationError:
88
- pass
89
-
90
-
91
- def test_dot_numbering_format():
92
- """Handle numbered format with dot (1. 2. 3.)."""
93
- from core.text_gen import extract_sentences
94
- result = extract_sentences("1. First.\n2. Second.")
95
- assert len(result) == 2
96
- assert result[0] == "First."
97
 
98
 
99
- def test_paren_numbering_format():
100
- """Handle numbered format with paren (1) 2) 3)."""
101
- from core.text_gen import extract_sentences
102
- result = extract_sentences("1) First.\n2) Second.")
103
- assert len(result) == 2
104
- assert result[0] == "First."
105
 
106
 
107
- def test_uncapped_extraction():
108
- """Extract many sentences without limit."""
109
- from core.text_gen import extract_sentences
110
- raw = "\n".join(f"{i}. Sentence number {i}." for i in range(1, 21))
111
- result = extract_sentences(raw)
112
- assert len(result) == 20
113
 
114
 
115
- def test_generate_sentences_success():
116
- """generate_sentences returns clean sentences on first try."""
117
- from unittest.mock import MagicMock
118
- from core.text_gen import generate_sentences
119
- from core.types import CEFRLevel
120
 
 
 
121
  mock_llm = MagicMock()
122
- mock_llm.create_chat_completion.return_value = {
123
- "choices": [{"message": {"content": "1. Hello world.\n2. Goodbye world."}}]
124
- }
125
 
126
  result = generate_sentences(
127
- scenario="greetings",
128
  cefr_level=CEFRLevel.A1,
129
  batch_size=2,
130
  llm=mock_llm,
131
  )
132
  assert len(result) == 2
133
- assert result[0] == "Hello world."
134
- assert result[1] == "Goodbye world."
135
-
136
 
137
- def test_generate_sentences_uncapped():
138
- """generate_sentences extracts all numbered sentences โ€” no cap."""
139
- from unittest.mock import MagicMock
140
- from core.text_gen import generate_sentences
141
- from core.types import CEFRLevel
142
 
 
 
143
  mock_llm = MagicMock()
144
  mock_llm.create_chat_completion.return_value = {
145
  "choices": [{"message": {"content": "1. First.\n2. Second.\n3. Third.\n4. Fourth."}}]
@@ -151,17 +119,13 @@ def test_generate_sentences_uncapped():
151
  batch_size=2,
152
  llm=mock_llm,
153
  )
154
- assert len(result) == 4 # All extracted, not truncated to batch_size
 
155
 
156
 
157
  def test_generate_sentences_retry_on_fewer_than_batch():
158
- """generate_sentences retries when fewer than batch_size sentences."""
159
- from unittest.mock import MagicMock, call
160
- from core.text_gen import generate_sentences, ValidationError
161
- from core.types import CEFRLevel
162
-
163
  mock_llm = MagicMock()
164
- # First call returns only 1 sentence (batch_size=3)
165
  mock_llm.create_chat_completion.side_effect = [
166
  {"choices": [{"message": {"content": "1. Only one sentence."}}]},
167
  {"choices": [{"message": {"content": "2. Second.\n3. Third.\n4. Fourth."}}]},
@@ -173,22 +137,19 @@ def test_generate_sentences_retry_on_fewer_than_batch():
173
  batch_size=3,
174
  llm=mock_llm,
175
  )
176
- assert len(result) == 3 # Retry gave us enough
177
- # Verify retry was called (2 calls total)
178
  assert mock_llm.create_chat_completion.call_count == 2
179
 
180
 
181
  def test_generate_sentences_fallback_after_exhausted_retries():
182
- """generate_sentences returns whatever it got after retries."""
183
- from unittest.mock import MagicMock
184
- from core.text_gen import generate_sentences, ValidationError
185
- from core.types import CEFRLevel
186
-
187
  mock_llm = MagicMock()
188
- # First call: too few (1 sentence). Second call: still few (2 sentences, batch_size=3)
 
189
  mock_llm.create_chat_completion.side_effect = [
190
  {"choices": [{"message": {"content": "1. Only one."}}]},
191
  {"choices": [{"message": {"content": "2. Second.\n3. Third."}}]},
 
192
  ]
193
 
194
  result = generate_sentences(
@@ -197,91 +158,38 @@ def test_generate_sentences_fallback_after_exhausted_retries():
197
  batch_size=3,
198
  llm=mock_llm,
199
  )
200
- # Returns 2 sentences (fewer than batch_size but that's all we got)
201
  assert len(result) == 2
202
 
203
 
204
- def test_generate_sentences_with_thinking_tags():
205
- """generate_sentences handles LLM output containing thinking tags."""
206
- from unittest.mock import MagicMock
207
- from core.text_gen import generate_sentences
208
- from core.types import CEFRLevel
209
-
210
  mock_llm = MagicMock()
211
  mock_llm.create_chat_completion.return_value = {
212
- "choices": [{
213
- "message": {
214
- "content": "<thinking>Let me think about this\nThe scenario is greetings</thinking>\n1. Hello there.\n2. How are you?"
215
- }
216
- }]
217
  }
218
 
219
  result = generate_sentences(
220
- scenario="greetings",
221
  cefr_level=CEFRLevel.A1,
222
  batch_size=2,
223
  llm=mock_llm,
224
  )
225
  assert len(result) == 2
226
- assert result[0] == "Hello there."
227
- assert result[1] == "How are you?"
228
-
229
 
230
- def test_generate_sentences_with_questions():
231
- """generate_sentences handles question sentences."""
232
- from unittest.mock import MagicMock
233
- from core.text_gen import generate_sentences
234
- from core.types import CEFRLevel
235
 
 
 
236
  mock_llm = MagicMock()
237
  mock_llm.create_chat_completion.return_value = {
238
- "choices": [{"message": {"content": "1. What is your name?\n2. Where do you live?"}}]
239
  }
240
 
241
  result = generate_sentences(
242
- scenario="introductions",
243
  cefr_level=CEFRLevel.A1,
244
  batch_size=2,
245
  llm=mock_llm,
246
  )
247
  assert len(result) == 2
248
- assert result[0] == "What is your name?"
249
- assert result[1] == "Where do you live?"
250
-
251
-
252
- if __name__ == "__main__":
253
- test_exact_count()
254
- print("test_exact_count: PASS")
255
- test_thinking_tag_stripping()
256
- print("test_thinking_tag_stripping: PASS")
257
- test_questions_and_exclamations()
258
- print("test_questions_and_exclamations: PASS")
259
- test_mixed_punctuation_with_thinking()
260
- print("test_mixed_punctuation_with_thinking: PASS")
261
- test_too_few_raises_validationerror()
262
- print("test_too_few_raises_validationerror: PASS")
263
- test_all_kept_no_truncation()
264
- print("test_all_kept_no_truncation: PASS")
265
- test_ignores_non_numbered_lines()
266
- print("test_ignores_non_numbered_lines: PASS")
267
- test_empty_after_tag_stripping_raises()
268
- print("test_empty_after_tag_stripping_raises: PASS")
269
- test_dot_numbering_format()
270
- print("test_dot_numbering_format: PASS")
271
- test_paren_numbering_format()
272
- print("test_paren_numbering_format: PASS")
273
- test_uncapped_extraction()
274
- print("test_uncapped_extraction: PASS")
275
- test_generate_sentences_success()
276
- print("test_generate_sentences_success: PASS")
277
- test_generate_sentences_uncapped()
278
- print("test_generate_sentences_uncapped: PASS")
279
- test_generate_sentences_retry_on_fewer_than_batch()
280
- print("test_generate_sentences_retry_on_fewer_than_batch: PASS")
281
- test_generate_sentences_fallback_after_exhausted_retries()
282
- print("test_generate_sentences_fallback_after_exhausted_retries: PASS")
283
- test_generate_sentences_with_thinking_tags()
284
- print("test_generate_sentences_with_thinking_tags: PASS")
285
- test_generate_sentences_with_questions()
286
- print("test_generate_sentences_with_questions: PASS")
287
- print("\nAll tests passed.")
 
1
+ """Tests for core.text_gen.extract_sentences and generate_sentences.
2
 
3
+ Merged from count_enforcement_test.py and extract_sentences_test.py.
4
+ All tests use mocking โ€” no LLM inference needed.
5
+ """
6
 
7
+ import pytest
8
+ from unittest.mock import MagicMock
9
 
10
+ from core.text_gen import extract_sentences, generate_sentences
11
+ from core.types import CEFRLevel, ValidationError
12
 
13
+
14
+ # โ”€โ”€ extract_sentences โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
15
+
16
+ def test_extract_sentences_basic_numbered_format():
17
+ """Basic numbered format: '1. Hello.\n2. World.' โ†’ ['Hello.', 'World.']"""
18
  result = extract_sentences("1. Hello world.\n2. Goodbye world.")
19
  assert len(result) == 2
20
  assert result[0] == "Hello world."
21
  assert result[1] == "Goodbye world."
22
 
23
 
24
+ def test_extract_sentences_thinking_tag_stripping():
25
+ """Strips <thinking> tags before parsing."""
 
26
  raw = "<thinking>some thoughts\nmore thoughts</thinking>\n1. Sentence one.\n2. Sentence two."
27
  result = extract_sentences(raw)
28
  assert len(result) == 2
 
30
  assert result[1] == "Sentence two."
31
 
32
 
33
+ def test_extract_sentences_mixed_punctuation():
34
+ """Sentences ending with ., ?, ! all recognized."""
 
35
  raw = "1. Hello.\n2. How are you?\n3. What a day!"
36
  result = extract_sentences(raw)
37
  assert len(result) == 3
 
40
  assert result[2] == "What a day!"
41
 
42
 
43
+ def test_extract_sentences_zero_sentences_raises():
44
+ """Zero numbered sentences raises ValidationError."""
45
+ with pytest.raises(ValidationError):
 
 
 
 
 
 
 
 
 
 
 
 
46
  extract_sentences("No numbered lines here.\nJust plain text.")
 
 
 
47
 
48
 
49
+ def test_extract_sentences_uncapped_20_sentences():
50
+ """20 numbered sentences all returned โ€” no upper cap."""
51
+ lines = "\n".join(f"{i}. Sentence {i}." for i in range(1, 21))
52
+ result = extract_sentences(lines)
53
+ assert len(result) == 20
54
+ assert result[0] == "Sentence 1."
55
+ assert result[19] == "Sentence 20."
 
56
 
57
 
58
+ def test_extract_sentences_ignores_non_numbered_lines():
59
+ """Non-numbered lines silently ignored, not discarded."""
 
60
  raw = "Some intro text.\n1. Valid sentence.\nMore text.\n2. Another valid."
61
  result = extract_sentences(raw)
62
  assert len(result) == 2
 
64
  assert result[1] == "Another valid."
65
 
66
 
67
+ def test_extract_sentences_dot_numbering_format():
68
+ """Dot numbering (1., 2.) format recognized."""
69
+ raw = "1. First.\n2. Second.\n3. Third."
70
+ result = extract_sentences(raw)
71
+ assert len(result) == 3
72
+ assert result == ["First.", "Second.", "Third."]
 
 
 
 
 
 
 
 
 
 
73
 
74
 
75
+ def test_extract_sentences_paren_numbering_format():
76
+ """Paren numbering (1), 2)) format recognized."""
77
+ raw = "1) First.\n2) Second.\n3) Third."
78
+ result = extract_sentences(raw)
79
+ assert len(result) == 3
80
+ assert result == ["First.", "Second.", "Third."]
81
 
82
 
83
+ def test_extract_sentences_empty_after_tag_stripping_raises():
84
+ """Raw text contains only thinking tags โ†’ ValidationError."""
85
+ with pytest.raises(ValidationError):
86
+ extract_sentences("<thinking>only reasoning</thinking>")
 
 
87
 
88
 
89
+ # โ”€โ”€ generate_sentences โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
 
 
 
90
 
91
+ def test_generate_sentences_success_first_try(mock_llm_response_factory):
92
+ """Success on first try with exact batch_size."""
93
  mock_llm = MagicMock()
94
+ mock_llm.create_chat_completion.return_value = mock_llm_response_factory(
95
+ "1. Hello.\n2. World."
96
+ )
97
 
98
  result = generate_sentences(
99
+ scenario="test",
100
  cefr_level=CEFRLevel.A1,
101
  batch_size=2,
102
  llm=mock_llm,
103
  )
104
  assert len(result) == 2
105
+ assert result[0] == "Hello."
106
+ assert result[1] == "World."
 
107
 
 
 
 
 
 
108
 
109
+ def test_generate_sentences_uncapped_extraction():
110
+ """More sentences than batch_size: returns all extracted (up to batch_size cap)."""
111
  mock_llm = MagicMock()
112
  mock_llm.create_chat_completion.return_value = {
113
  "choices": [{"message": {"content": "1. First.\n2. Second.\n3. Third.\n4. Fourth."}}]
 
119
  batch_size=2,
120
  llm=mock_llm,
121
  )
122
+ # batch_size is a cap: returns first 2
123
+ assert len(result) == 2
124
 
125
 
126
  def test_generate_sentences_retry_on_fewer_than_batch():
127
+ """Retries when fewer than batch_size sentences on first call."""
 
 
 
 
128
  mock_llm = MagicMock()
 
129
  mock_llm.create_chat_completion.side_effect = [
130
  {"choices": [{"message": {"content": "1. Only one sentence."}}]},
131
  {"choices": [{"message": {"content": "2. Second.\n3. Third.\n4. Fourth."}}]},
 
137
  batch_size=3,
138
  llm=mock_llm,
139
  )
140
+ assert len(result) == 3
 
141
  assert mock_llm.create_chat_completion.call_count == 2
142
 
143
 
144
  def test_generate_sentences_fallback_after_exhausted_retries():
145
+ """Returns whatever was produced after retries exhausted (3 LLM calls total)."""
 
 
 
 
146
  mock_llm = MagicMock()
147
+ # 1st call: 1 sentence. 2nd call: 2 sentences (< batch_size=3, retries).
148
+ # 3rd call: same output โ†’ returns 2 sentences after retry exhaustion.
149
  mock_llm.create_chat_completion.side_effect = [
150
  {"choices": [{"message": {"content": "1. Only one."}}]},
151
  {"choices": [{"message": {"content": "2. Second.\n3. Third."}}]},
152
+ {"choices": [{"message": {"content": "2. Second.\n3. Third."}}]}, # attempt 3, returns result
153
  ]
154
 
155
  result = generate_sentences(
 
158
  batch_size=3,
159
  llm=mock_llm,
160
  )
 
161
  assert len(result) == 2
162
 
163
 
164
+ def test_generate_sentences_thinking_tags_handled():
165
+ """LLM output containing thinking tags handled correctly."""
 
 
 
 
166
  mock_llm = MagicMock()
167
  mock_llm.create_chat_completion.return_value = {
168
+ "choices": [{"message": {"content": "<thinking>reasoning</thinking>\n1. Hello.\n2. World."}}]
 
 
 
 
169
  }
170
 
171
  result = generate_sentences(
172
+ scenario="test",
173
  cefr_level=CEFRLevel.A1,
174
  batch_size=2,
175
  llm=mock_llm,
176
  )
177
  assert len(result) == 2
178
+ assert result[0] == "Hello."
 
 
179
 
 
 
 
 
 
180
 
181
+ def test_generate_sentences_question_sentences_preserved():
182
+ """Question sentences preserved in output."""
183
  mock_llm = MagicMock()
184
  mock_llm.create_chat_completion.return_value = {
185
+ "choices": [{"message": {"content": "1. What is this?\n2. It is a cat."}}]
186
  }
187
 
188
  result = generate_sentences(
189
+ scenario="test",
190
  cefr_level=CEFRLevel.A1,
191
  batch_size=2,
192
  llm=mock_llm,
193
  )
194
  assert len(result) == 2
195
+ assert result[0] == "What is this?"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/widgets_test.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for frontend.ui.widgets widget creation and UI state helpers."""
2
+
3
+ import sys
4
+ import pytest
5
+ from unittest.mock import patch, MagicMock
6
+
7
+
8
+ @pytest.fixture(autouse=True)
9
+ def _mock_gradio():
10
+ """Mock gradio module before any widget functions are called."""
11
+ mock_gr = MagicMock()
12
+ mock_gr.Blocks = MagicMock()
13
+ mock_gr.Checkbox = MagicMock()
14
+ mock_gr.Button = MagicMock()
15
+ mock_gr.Dropdown = MagicMock()
16
+ with patch.dict('sys.modules', {'gradio': mock_gr}):
17
+ yield mock_gr
18
+
19
+
20
+ def test_create_toggle_label_with_emoji(_mock_gradio):
21
+ """Toggle label includes the provided emoji prefix."""
22
+ from frontend.ui.widgets import create_toggle
23
+
24
+ checkbox = create_toggle("๐Ÿ–ผ๏ธ Images", value=True, elem_id="toggle-images")
25
+ _mock_gradio.Checkbox.assert_called()
26
+ call_kwargs = _mock_gradio.Checkbox.call_args[1]
27
+ assert "Images" in str(call_kwargs.get("label", ""))
28
+
29
+
30
+ def test_create_toggle_default_value(_mock_gradio):
31
+ """Toggle respects the default value parameter."""
32
+ from frontend.ui.widgets import create_toggle
33
+
34
+ checkbox_false = create_toggle("๐Ÿ”Š Audio", value=False, elem_id="toggle-audio")
35
+ call_kwargs = _mock_gradio.Checkbox.call_args[1]
36
+ assert call_kwargs.get("value") is False
37
+
38
+
39
+ def test_create_toggle_elem_id_generation(_mock_gradio):
40
+ """elem_id follows the pattern toggle-<label-without-emoji>."""
41
+ from frontend.ui.widgets import create_toggle
42
+
43
+ create_toggle("๐Ÿ–ผ๏ธ Images", value=True, elem_id="toggle-images")
44
+ call_kwargs = _mock_gradio.Checkbox.call_args[1]
45
+ assert call_kwargs.get("elem_id") == "toggle-images"
46
+
47
+
48
+ def test_create_voice_dropdown_all_choices(_mock_gradio):
49
+ """All 6 voice choices present in dropdown."""
50
+ from frontend.ui.widgets import create_voice_dropdown
51
+
52
+ dropdown = create_voice_dropdown()
53
+ call_kwargs = _mock_gradio.Dropdown.call_args[1]
54
+ choices = call_kwargs.get("choices", [])
55
+ assert len(choices) == 6
56
+
57
+
58
+ def test_create_voice_dropdown_default_value(_mock_gradio):
59
+ """Default value is 'female, young adult' (instruct string)."""
60
+ from frontend.ui.widgets import create_voice_dropdown
61
+
62
+ create_voice_dropdown()
63
+ call_kwargs = _mock_gradio.Dropdown.call_args[1]
64
+ default = call_kwargs.get("value")
65
+ assert default == "female, young adult"
66
+
67
+
68
+ def test_create_voice_dropdown_elem_id(_mock_gradio):
69
+ """Voice dropdown elem_id is 'voice-dropdown'."""
70
+ from frontend.ui.widgets import create_voice_dropdown
71
+
72
+ create_voice_dropdown()
73
+ call_kwargs = _mock_gradio.Dropdown.call_args[1]
74
+ assert call_kwargs.get("elem_id") == "voice-dropdown"
75
+
76
+
77
+ def test_voice_map_all_six_entries():
78
+ """_VOICE_MAP has exactly 6 entries mapping display labels to instruct strings."""
79
+ from frontend.ui.widgets import _VOICE_MAP
80
+
81
+ assert len(_VOICE_MAP) == 6
82
+
83
+
84
+ def test_voice_map_instruct_strings_format():
85
+ """All _VOICE_MAP values are comma-separated gender, age format."""
86
+ from frontend.ui.widgets import _VOICE_MAP
87
+
88
+ for label, instruct in _VOICE_MAP.items():
89
+ parts = instruct.split(", ")
90
+ assert len(parts) == 2
91
+ assert parts[0] in ("female", "male")
92
+ assert parts[1] in ("young adult", "middle-aged", "senior", "teenager")
93
+
94
+
95
+ def test_enable_phase2_returns_tuple(_mock_gradio):
96
+ """_enable_phase2() returns tuple of (Checkbox, Checkbox, Button, Dropdown, "")."""
97
+ from frontend.ui.widgets import _enable_phase2
98
+
99
+ result = _enable_phase2()
100
+ assert isinstance(result, tuple)
101
+ assert len(result) == 5
102
+
103
+
104
+ def test_reset_to_idle_returns_tuple(_mock_gradio):
105
+ """_reset_to_idle() returns tuple with interactive=False, disabled CSS string."""
106
+ from frontend.ui.widgets import _reset_to_idle
107
+
108
+ result = _reset_to_idle()
109
+ assert isinstance(result, tuple)
110
+ assert len(result) == 6
111
+ # Last element should be a CSS string (non-empty)
112
+ assert isinstance(result[5], str)
113
+ assert len(result[5]) > 0
114
+
115
+
116
+ def test_reset_to_idle_disabled_css_content(_mock_gradio):
117
+ """_reset_to_idle() CSS targets the right elem_ids."""
118
+ from frontend.ui.widgets import _reset_to_idle
119
+
120
+ result = _reset_to_idle()
121
+ css = result[5]
122
+ assert "europalex-btn-disabled" in css or "toggle-images" in css
123
+ assert "#voice-dropdown" in css
124
+
125
+
126
+ def test_enable_language_dropdown_on_audio_true(_mock_gradio):
127
+ """Audio toggle ON โ†’ removes disabled CSS, enables dropdown."""
128
+ from frontend.ui.widgets import _enable_language_dropdown_on_audio
129
+
130
+ result = _enable_language_dropdown_on_audio(True)
131
+ assert isinstance(result, tuple)
132
+ # Should return (dropdown_update, "") โ€” empty CSS means enabled
133
+ assert result[1] == ""
134
+
135
+
136
+ def test_enable_language_dropdown_on_audio_false(_mock_gradio):
137
+ """Audio toggle OFF โ†’ applies disabled CSS to voice dropdown."""
138
+ from frontend.ui.widgets import _enable_language_dropdown_on_audio
139
+
140
+ result = _enable_language_dropdown_on_audio(False)
141
+ assert isinstance(result, tuple)
142
+ # Should return (dropdown_update, css_string) โ€” non-empty CSS means disabled
143
+ assert isinstance(result[1], str)
144
+ assert len(result[1]) > 0
145
+ assert "#voice-dropdown" in result[1]