Spaces:
Build error
Build error
feat: test use pytest and test all modules
Browse files- AGENTS.md +31 -20
- README.md +20 -3
- docs/superpowers/plans/2026-06-13-pytest-migration.md +1892 -0
- tests/app_test.py +208 -0
- tests/audio_gen_test.py +164 -0
- tests/cards_test.py +196 -0
- tests/conftest.py +59 -0
- tests/count_enforcement_test.py +0 -93
- tests/{translation_retry_test.py โ engine_test.py} +164 -80
- tests/image_gen_test.py +127 -0
- tests/pipeline_test.py +148 -0
- tests/progression_test.py +0 -127
- tests/smoke_test.py +121 -105
- tests/{extract_sentences_test.py โ text_gen_test.py} +74 -166
- tests/widgets_test.py +145 -0
AGENTS.md
CHANGED
|
@@ -232,33 +232,44 @@ Phase 2 translation orchestration layer. Provides `generate_phase2()` as a gener
|
|
| 232 |
|
| 233 |
## Testing Expectations
|
| 234 |
|
| 235 |
-
###
|
| 236 |
|
| 237 |
-
|
| 238 |
|
| 239 |
```bash
|
| 240 |
-
|
| 241 |
```
|
| 242 |
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
-
###
|
| 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 |
-
|
| 258 |
|
| 259 |
-
###
|
| 260 |
|
| 261 |
-
The
|
| 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
|
| 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 `
|
| 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
|
| 35 |
|
| 36 |
-
All
|
| 37 |
|
| 38 |
```bash
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
output, and fallback โ without requiring a running model. Uses mock LLM.
|
| 5 |
"""
|
| 6 |
|
| 7 |
-
import
|
| 8 |
-
from
|
| 9 |
|
| 10 |
-
|
|
|
|
| 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 |
-
"""
|
| 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 |
-
"""
|
| 64 |
-
from core.types import CEFRLevel
|
| 65 |
-
from core.engine import LlamaCppTextEngine
|
| 66 |
-
|
| 67 |
mock_llm = MagicMock()
|
| 68 |
-
# All 3 attempts return
|
| 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 |
-
"""
|
| 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": "
|
| 95 |
-
{"choices": [{"message": {"content": "
|
| 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 == "
|
| 106 |
assert mock_llm.create_chat_completion.call_count == 2
|
| 107 |
-
print("test_translate_single_multiline_rejected: PASS")
|
| 108 |
|
| 109 |
|
| 110 |
-
|
| 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 |
-
"""
|
| 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 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
#
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
| 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
|
| 20 |
-
"""
|
| 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
|
| 30 |
-
"""
|
| 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
|
| 41 |
-
"""
|
| 42 |
-
|
| 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
|
| 62 |
-
"""
|
| 63 |
-
|
| 64 |
-
result = extract_sentences(
|
| 65 |
-
assert len(result) ==
|
| 66 |
-
assert result[0] == "
|
| 67 |
-
assert result[
|
| 68 |
-
assert result[2] == "Third."
|
| 69 |
|
| 70 |
|
| 71 |
-
def
|
| 72 |
-
"""Non-numbered lines
|
| 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
|
| 82 |
-
"""
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 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
|
| 100 |
-
"""
|
| 101 |
-
|
| 102 |
-
result = extract_sentences(
|
| 103 |
-
assert len(result) ==
|
| 104 |
-
assert result
|
| 105 |
|
| 106 |
|
| 107 |
-
def
|
| 108 |
-
"""
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
result = extract_sentences(raw)
|
| 112 |
-
assert len(result) == 20
|
| 113 |
|
| 114 |
|
| 115 |
-
|
| 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 |
-
"
|
| 124 |
-
|
| 125 |
|
| 126 |
result = generate_sentences(
|
| 127 |
-
scenario="
|
| 128 |
cefr_level=CEFRLevel.A1,
|
| 129 |
batch_size=2,
|
| 130 |
llm=mock_llm,
|
| 131 |
)
|
| 132 |
assert len(result) == 2
|
| 133 |
-
assert result[0] == "Hello
|
| 134 |
-
assert result[1] == "
|
| 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 |
-
|
|
|
|
| 155 |
|
| 156 |
|
| 157 |
def test_generate_sentences_retry_on_fewer_than_batch():
|
| 158 |
-
"""
|
| 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
|
| 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 |
-
"""
|
| 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 |
-
#
|
|
|
|
| 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
|
| 205 |
-
"""
|
| 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="
|
| 221 |
cefr_level=CEFRLevel.A1,
|
| 222 |
batch_size=2,
|
| 223 |
llm=mock_llm,
|
| 224 |
)
|
| 225 |
assert len(result) == 2
|
| 226 |
-
assert result[0] == "Hello
|
| 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
|
| 239 |
}
|
| 240 |
|
| 241 |
result = generate_sentences(
|
| 242 |
-
scenario="
|
| 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
|
| 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]
|