Takosaga commited on
Commit
5d2aefc
Β·
1 Parent(s): 371681a

docs: add pytest migration design spec

Browse files
docs/superpowers/specs/2026-06-13-pytest-migration-design.md ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pytest Migration Design
2
+
3
+ **Status:** Approved
4
+ **Date:** 2026-06-13
5
+
6
+ ## Overview
7
+
8
+ Migrate all EuropaLex tests from `if __name__ == "__main__":` + `print()` style to proper pytest test files. Create new test files for modules that currently have no test coverage. Use `unittest.mock` for all model/engine mocking. Real audio and image files from `tests/test_outputs/` serve as file-existence fixtures.
9
+
10
+ ## Test File Structure
11
+
12
+ Flat structure in `tests/`, one file per source module:
13
+
14
+ ```
15
+ tests/
16
+ β”œβ”€β”€ conftest.py # Shared fixtures (mock data, paths, temp dirs)
17
+ β”œβ”€β”€ smoke_test.py # Pytest rewrite: import validation + model construction
18
+ β”œβ”€β”€ cards_test.py # Card HTML rendering functions
19
+ β”œβ”€β”€ widgets_test.py # Widget creation and UI state helpers
20
+ β”œβ”€β”€ app_test.py # App async generators and helpers
21
+ β”œβ”€β”€ audio_gen_test.py # TTSEngine (audio generation)
22
+ β”œβ”€β”€ image_gen_test.py # ImageGenEngine (image generation)
23
+ β”œβ”€β”€ engine_test.py # MiniCPMTextEngine, LlamaCppTextEngine, EnginePool
24
+ β”œβ”€β”€ pipeline_test.py # Phase 2 orchestration
25
+ └── text_gen_test.py # Merged: extract_sentences + generate_sentences
26
+ ```
27
+
28
+ ## Existing Files Consolidated
29
+
30
+ | Old File | Destination | Notes |
31
+ |---|---|---|
32
+ | `count_enforcement_test.py` | `text_gen_test.py` | Same module, overlapping tests β€” merged |
33
+ | `extract_sentences_test.py` | `text_gen_test.py` | Same module β€” merged |
34
+ | `translation_retry_test.py` | `engine_test.py` | Tests LlamaCppTextEngine |
35
+ | `progression_test.py` | Split: `_progress_pct` β†’ `app_test.py`, card progression β†’ `cards_test.py` | Two distinct concerns |
36
+
37
+ ## Mock Data (conftest.py fixtures)
38
+
39
+ ### English Text (Phase 1)
40
+ ```python
41
+ mock_english_texts = [
42
+ "I love eating fresh fruits.",
43
+ "She enjoys cooking pasta.",
44
+ "The chef prepared a delicious meal.",
45
+ ]
46
+ ```
47
+
48
+ ### Spanish Translation (Phase 2)
49
+ ```python
50
+ mock_spanish_translations = [
51
+ "Me encanta comer frutas frescas.",
52
+ "Le encanta cocinar pasta.",
53
+ "El chef preparΓ³ una comida deliciosa.",
54
+ ]
55
+ ```
56
+
57
+ ### Real Media Files
58
+ - `mock_audio_paths`: Real `.wav` paths from `tests/test_outputs/audio/` (3 files)
59
+ - `mock_image_paths`: Real `.png` paths from `tests/test_outputs/images/` (3 files)
60
+
61
+ Used for file-existence assertions in card HTML tests and as return values for mocked TTS/image engines.
62
+
63
+ ## Mocking Strategy
64
+
65
+ ### Gradio (`widgets.py`)
66
+ - Patch `import gradio as gr` at module level using `conftest.py` fixtures or per-test mocks
67
+ - Test non-Gradio parts directly: `_VOICE_MAP`, CSS helper return values
68
+ - `build_ui()` tested by verifying it returns a `gr.Blocks` instance with expected widget types
69
+
70
+ ### GPU Engines (`engine.py`, `audio_gen.py`, `image_gen.py`)
71
+ - All model loading (`_load_model()`, `_load_pipeline()`) patched via `unittest.mock.patch`
72
+ - Tests verify: correct method calls, retry loop behavior (side_effect chains), result wrapper correctness, unload behavior
73
+ - No actual GPU or model weights needed
74
+
75
+ ### Pipeline (`pipeline.py`)
76
+ - EnginePool and individual engines mocked
77
+ - Tests verify orchestration flow: translation β†’ TTS β†’ CardData assembly
78
+ - Progress percentage calculations verified at each step
79
+
80
+ ### App Async Generators (`app.py`)
81
+ - Generator functions consumed via `list()` to capture all yields
82
+ - Engine methods mocked; tests assert on sequence of `(progress_html, cards_html)` tuples
83
+ - Error handling paths tested by raising exceptions in mock engines
84
+
85
+ ## Fixture Strategy (conftest.py)
86
+
87
+ | Fixture | Type | Purpose |
88
+ |---|---|---|
89
+ | `mock_english_texts` | list[str] | Phase 1 English sentences |
90
+ | `mock_spanish_translations` | list[str] | Phase 2 Spanish translations |
91
+ | `mock_audio_paths` | list[str] | Real .wav paths for file-existence tests |
92
+ | `mock_image_paths` | list[str] | Real .png paths for file-existence tests |
93
+ | `temp_output_dir` | Path (tmp_path) | Temp dir for TTS/image generation tests, auto-cleaned |
94
+ | `mock_llm_response_factory` | callable | Helper to build LLM response dicts: `{"choices": [{"message": {"content": "..."}}]}` |
95
+
96
+ ## Detailed Test Coverage Per File
97
+
98
+ ### `conftest.py`
99
+ - All shared fixtures listed above
100
+
101
+ ### `smoke_test.py` (pytest rewrite)
102
+ - Import validation for all modules
103
+ - Pydantic model construction: CardData, TextResult, AudioResult, ImageResult, EngineConfig
104
+ - `TextResult.validate_and_parse()` gate: thinking-tag stripping, count enforcement, ValidationError on mismatch
105
+
106
+ ### `cards_test.py`
107
+ **`render_card_html()`:**
108
+ - Placeholder mode (English front, dashed back)
109
+ - Normal mode (translation front, English back)
110
+ - With image (existing file β†’ `<img>` tag; missing file β†’ placeholder emoji)
111
+ - With audio (existing file β†’ `<audio>` element; missing file β†’ play button)
112
+ - Rotation parameter applied to transform style
113
+
114
+ **`generate_cards_html()`:**
115
+ - Empty cards list β†’ "No cards" message
116
+ - Single card, multi-card rotation distribution
117
+ - Media toggle combinations: image-only, audio-only, both, neither
118
+ - Placeholder back mode
119
+
120
+ **`generate_progress_html()`:**
121
+ - 0% β†’ empty string (hidden)
122
+ - Mid-progress: color transitions at 10%/60%/100%
123
+ - 100%: dark brown bar, green "complete" text
124
+
125
+ ### `widgets_test.py`
126
+ **`create_toggle()`:**
127
+ - Label rendering with emoji
128
+ - Default value (True/False)
129
+ - elem_id generation from label text
130
+
131
+ **`create_voice_dropdown()`:**
132
+ - All 6 voice choices present
133
+ - Default value matches first choice
134
+ - elem_id = "voice-dropdown"
135
+
136
+ **`_VOICE_MAP`:**
137
+ - All 6 display labels map to correct instruct strings
138
+
139
+ **State helpers:**
140
+ - `_enable_phase2()`: returns tuple of (Checkbox, Checkbox, Button, Dropdown, "") with interactive=True
141
+ - `_reset_to_idle()`: returns tuple with interactive=False, disabled CSS string
142
+ - `_enable_language_dropdown_on_audio(True)`: removes disabled CSS, enables dropdown
143
+ - `_enable_language_dropdown_on_audio(False)`: applies disabled CSS to voice dropdown
144
+
145
+ ### `app_test.py`
146
+ **`transform_mock_cards()`:**
147
+ - Legacy format β†’ new format: `{"front": "X", "back": "Y"}` β†’ `{"text": "Y", "translation": "X"}`
148
+ - Empty input returns empty list
149
+ - Multiple cards preserved in order
150
+
151
+ **`_progress_pct()`:**
152
+ - Single sentence (total=1): always 100% with "complete" label
153
+ - Two sentences: step 0 β†’ ~50%, step 1 β†’ 100%
154
+ - Five sentences: all steps verified for percentage and remaining count in label
155
+
156
+ **`generate_text_async()`:**
157
+ - Generator function structure (isgeneratorfunction check)
158
+ - Mock engine integration: yields progress updates then card HTML
159
+ - Error handling: FileNotFoundError path, general exception path
160
+
161
+ **`generate_media_async()`:**
162
+ - Generator function structure
163
+ - Per-sentence card progression: cards grow with each yield
164
+ - TTS toggle: yields audio generation progress when enabled
165
+ - Images toggle: yields image generation progress when enabled
166
+ - Missing Phase 1 texts error path
167
+
168
+ ### `audio_gen_test.py`
169
+ **`TTSEngine.synthesize()`:**
170
+ - Success path: mock model returns audio data β†’ .wav file written, path in result
171
+ - Failure path: mock model raises exception β†’ None in result list
172
+ - Empty input list: returns empty AudioResult
173
+ - Language and instruct parameters passed to model.generate()
174
+
175
+ **`TTSEngine.unload()`:**
176
+ - Model deleted, _loaded reset to False
177
+ - torch.cuda.empty_cache() called
178
+
179
+ ### `image_gen_test.py`
180
+ **`ImageGenEngine.generate()`:**
181
+ - Success path: mock pipeline returns images β†’ .png file written, path in result
182
+ - Failure path: mock pipeline raises exception β†’ None in result list
183
+ - Empty input list: returns empty ImageResult
184
+
185
+ **`ImageGenEngine.unload()`:**
186
+ - Pipeline deleted, _loaded reset to False
187
+ - torch.cuda.empty_cache() called
188
+
189
+ ### `engine_test.py`
190
+ **`MiniCPMTextEngine.generate()`:**
191
+ - Mock LLM integration: generate() calls generate_sentences which calls llm.create_chat_completion
192
+ - TextResult wrapping with generated_texts field
193
+ - ValidationError propagation from text_gen
194
+
195
+ **`LlamaCppTextEngine._translate_single()`:**
196
+ - Success on first attempt
197
+ - Invalid output retry (contains "english", empty, multiline)
198
+ - Exhausted retries β†’ fallback to original English text
199
+
200
+ **`LlamaCppTextEngine._is_valid_translation()`:**
201
+ - Valid: non-empty single line, no English words
202
+ - Invalid: empty string, whitespace-only, contains "translate"/"translation"/"english", multiline
203
+
204
+ **`LlamaCppTextEngine.generate()`:**
205
+ - Per-sentence translation loop: calls _translate_single for each input text
206
+ - TextResult wrapping
207
+ - Batch size matching
208
+
209
+ **`EnginePool.get()` / `.reset()`:**
210
+ - Singleton creation via get(config)
211
+ - Second get() returns same instance
212
+ - reset() clears singleton and unloads engines
213
+
214
+ ### `pipeline_test.py`
215
+ **`generate_phase2()`:**
216
+ - Translation-only: yields progress updates per sentence, final CardData list with translations
217
+ - Translation+TTS: additional yield at 70% for audio generation, CardData includes audio_paths
218
+ - Progress percentages: 20% (prepare), 15-70% (translation steps), 70% (audio start if enabled), 100% (complete)
219
+
220
+ **`ValidationError` propagation:**
221
+ - If translation fails after retries, ValidationError is raised and not caught by pipeline
222
+
223
+ ### `text_gen_test.py` (merged from count_enforcement_test.py + extract_sentences_test.py)
224
+ **`extract_sentences()`:**
225
+ - Basic numbered format: "1. Hello.\n2. World." β†’ ["Hello.", "World."]
226
+ - Thinking tag stripping: `<thinking>...</thinking>\n1. A.\n2. B.` β†’ ["A.", "B."]
227
+ - Mixed punctuation: sentences ending with `.`, `?`, `!`
228
+ - Zero sentences raises ValidationError
229
+ - Uncapped extraction: 20 numbered sentences all returned
230
+ - Non-numbered lines ignored silently
231
+ - Dot numbering (`1.`) and paren numbering (`1)`) formats
232
+ - Empty after tag stripping raises ValidationError
233
+
234
+ **`generate_sentences()`:**
235
+ - Success on first try with exact batch_size
236
+ - Retry when fewer than batch_size: second call provides enough
237
+ - Exhausted retries: returns what was produced (fewer than batch_size)
238
+ - Thinking tags in LLM output handled correctly
239
+ - Question sentences preserved
240
+
241
+ ## Running Tests
242
+
243
+ ```bash
244
+ cd /home/takosaga/Projects/EuropaLex
245
+ uv run pytest tests/ -v
246
+ ```
247
+
248
+ The `pyproject.toml` already has `[tool.pytest.ini_options]` with `testpaths = ["tests"]` and `python_files = "*_test.py"`. No config changes needed.
249
+
250
+ ## Out of Scope
251
+
252
+ - Testing actual model inference (all engines mocked)
253
+ - Testing Anki export (`export/` module) β€” not in current scope
254
+ - Integration tests with real Gradio server
255
+ - Performance benchmarks