Spaces:
Build error
Build error
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
|