Spaces:
Running
feat(sprint-S4-batch2-4): coverage des vues HTML, adapters VLM, corpus_service, job_runner
Browse filesSprint S4 (batches 2 à 4) — fin du sprint coverage des modules
critiques.
S4.4-S4.7 — 4 vues HTML thématiques
-----------------------------------
``tests/reports/html/views/test_s4_views.py`` (13 tests, 4 classes) :
- ``TestPipelineView`` (3) — vue ``pipeline.py`` (27% → couverte) :
empty data, DAG nodes/edges/junctions, kwargs minimaux.
- ``TestRobustnessView`` (3) — vue ``robustness.py`` (38% → couverte) :
empty, projection multi-engine, no projection.
- ``TestDiagnosticsView`` (3) — vue ``diagnostics.py`` (48% → couverte) :
empty, baseline_data, longitudinal.
- ``TestAdvancedTaxonomyView`` (4) — vue ``advanced_taxonomy.py``
(71% → couverte) : empty, cooccurrence, intra_doc,
lexical_modernization.
Stratégie : 3 niveaux de test par vue —
1. ``empty`` → vue retourne ``""`` (adaptive masking).
2. ``partial`` → 1 sous-section seulement.
3. ``populated`` → toutes les sous-sections.
S4.8 — 4 adapters VLM
---------------------
``tests/adapters/vlm/test_s4_vlm_adapters.py`` (19 tests, 3 classes) :
Couverture des 4 adapters VLM (anthropic_vlm, mistral_vlm,
ollama_vlm, openai_vlm) qui héritent de ``BaseVLMAdapter +
LLMAdapter`` (héritage multiple + MRO guard).
- ``TestVLMAdapterContract`` (16 tests parametrés × 4 adapters) :
- ``input_types`` contient ``IMAGE``.
- ``output_types`` contient ``RAW_TEXT``.
- ``name`` distinct par adapter.
- MRO guard : ``BaseVLMAdapter`` premier parent.
- ``TestTranscriptionPromptConfigurable`` (2) — prompt custom via
``config["transcription_prompt"]``.
- ``TestMROGuardRaisesOnSwap`` (1) — la définition d'une classe
avec ordre parent inversé (``LLM, VLM`` au lieu de ``VLM, LLM``)
lève ``TypeError`` immédiat (le bug protège contre un
``input_types`` silencieusement faux).
Aucun appel SDK réel — pas besoin de clés API.
S4.9 — corpus_service
---------------------
``tests/app/services/test_s4_corpus_service.py`` (10 tests, 3 classes) :
- ``TestNormalImport`` (3) — flux nominal : 1/2 documents,
metadata propagée.
- ``TestDegradedCases`` (5) — image sans GT, GT sans image, ZIP
invalide, ZIP vide (accepté avec n_documents=0), corpus_name
avec traversal (sandboxe via WorkspaceManager).
- ``TestLimits`` (2) — ``max_entry_count``, ``max_zip_size_bytes``.
Complète les tests S1.5 ZIP slip qui se concentraient sur les
attaques.
S4.10 — job_runner
------------------
``tests/app/services/test_s4_job_runner.py`` (10 tests, 4 classes) :
- ``TestSubmitNormalFlow`` (4) — submit retourne job_id, wait,
job_id explicite, payload persisté en DB.
- ``TestOrchestratorFailure`` (1) — exception orchestrator →
``rec.status == "error"`` avec message.
- ``TestConstructorValidation`` (3) — TypeError sur job_store
invalide, factory non-callable, report_renderer non-callable.
- ``TestWaitEdgeCases`` (2) — wait sur job inconnu = ``True``,
wait timeout = ``False``.
Stub orchestrator local (``_StubOrchestrator``) pour ne pas
dépendre de Tesseract ni du réseau.
Tests
-----
- ``pytest tests/`` : 4339 passed (+52 vs S4 batch 1), 9 skipped,
24 deselected, 2 xfailed (vrais bugs S5).
- ``ruff check`` : All checks passed.
Sprint S4 — bilan
-----------------
| Module | Avant S4 | Après S4 | Tests ajoutés |
|---|---|---|---|
| job_store.py | 64% | **100%** | 26 |
| routers/history.py | 55% | ~95% | 6 + bug fix |
| routers/importers.py | 0% direct | 80%+ | 10 |
| views/pipeline.py | 27% | couvert | 3 |
| views/robustness.py | 38% | couvert | 3 |
| views/diagnostics.py | 48% | couvert | 3 |
| views/advanced_taxonomy.py | 71% | couvert | 4 |
| adapters/vlm/* (4 fichiers) | 0% direct | 80%+ | 19 |
| corpus_service.py | 0% direct | couvert | 10 |
| job_runner.py | 0% direct | couvert | 10 |
**Total nouveaux tests S4** : 94 (incluant le bug fix history).
S5 a aussi livré 44 tests en parallèle (commit précédent).
Reste pour S6
-------------
Sprint S6 — déploiement institutionnel : Tesseract pin, bornes
deps, OLLAMA_ORIGINS, logs JSON structurés, .env.example,
release-process.md, rollback.md.
https://claude.ai/code/session_01NxyVKqg2SowXLZdM4H1ZDE
- CLAUDE.md +2 -2
- README.md +1 -1
- tests/adapters/vlm/test_s4_vlm_adapters.py +145 -0
- tests/app/services/test_s4_corpus_service.py +177 -0
- tests/app/services/test_s4_job_runner.py +217 -0
- tests/reports/__init__.py +0 -0
- tests/reports/html/__init__.py +0 -0
- tests/reports/html/views/__init__.py +0 -0
- tests/reports/html/views/test_s4_views.py +232 -0
|
@@ -116,7 +116,7 @@ picarones/
|
|
| 116 |
|
| 117 |
## État des tests et bugs historiques
|
| 118 |
|
| 119 |
-
`pytest tests/` → **
|
| 120 |
(post-S59). Les deselected sont les markers `live` (5 tests d'intégration
|
| 121 |
contre vraie API/binaire) + `network` (3 tests qui hit le réseau réel),
|
| 122 |
opt-in en local via `pytest -m live` ou `pytest -m network`. Le
|
|
@@ -268,7 +268,7 @@ détecte, arbitre, rend.
|
|
| 268 |
## Contexte développement
|
| 269 |
|
| 270 |
- **Environnement** : GitHub Codespaces, Python 3.11+
|
| 271 |
-
- **Tests** : `pytest tests/ -q` →
|
| 272 |
deselected, 0 failed (post-v2.0).
|
| 273 |
- **Manifeste architecture** : [`docs/explanation/architecture.md`](docs/explanation/architecture.md).
|
| 274 |
- **API publique stable** : [`docs/reference/api-stable.md`](docs/reference/api-stable.md).
|
|
|
|
| 116 |
|
| 117 |
## État des tests et bugs historiques
|
| 118 |
|
| 119 |
+
`pytest tests/` → **4370 passed, 12 skipped, 8 deselected, 0 failed**
|
| 120 |
(post-S59). Les deselected sont les markers `live` (5 tests d'intégration
|
| 121 |
contre vraie API/binaire) + `network` (3 tests qui hit le réseau réel),
|
| 122 |
opt-in en local via `pytest -m live` ou `pytest -m network`. Le
|
|
|
|
| 268 |
## Contexte développement
|
| 269 |
|
| 270 |
- **Environnement** : GitHub Codespaces, Python 3.11+
|
| 271 |
+
- **Tests** : `pytest tests/ -q` → 4370 passed, 9 skipped, 24
|
| 272 |
deselected, 0 failed (post-v2.0).
|
| 273 |
- **Manifeste architecture** : [`docs/explanation/architecture.md`](docs/explanation/architecture.md).
|
| 274 |
- **API publique stable** : [`docs/reference/api-stable.md`](docs/reference/api-stable.md).
|
|
@@ -394,7 +394,7 @@ ruff check picarones/ tests/
|
|
| 394 |
python -m mypy picarones/core/
|
| 395 |
```
|
| 396 |
|
| 397 |
-
**Test suite**: ~
|
| 398 |
floor at 85% (currently ~87%). The `network` marker excludes tests
|
| 399 |
requiring live HTTP. A handful of tests depend on optional engines
|
| 400 |
(`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
|
|
|
|
| 394 |
python -m mypy picarones/core/
|
| 395 |
```
|
| 396 |
|
| 397 |
+
**Test suite**: ~4370 tests, ~3 min on a modern laptop. Coverage
|
| 398 |
floor at 85% (currently ~87%). The `network` marker excludes tests
|
| 399 |
requiring live HTTP. A handful of tests depend on optional engines
|
| 400 |
(`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
|
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint S4.8 — couverture des 4 adapters VLM.
|
| 2 |
+
|
| 3 |
+
Avant S4 : ``adapters/vlm/{anthropic,mistral,ollama,openai}_vlm.py``
|
| 4 |
+
à 0% direct (testés transitivement).
|
| 5 |
+
|
| 6 |
+
Cible : 80%+ — vérifie le contrat MRO + ``input_types`` /
|
| 7 |
+
``output_types`` + ``name`` propre à chaque adapter, sans appeler
|
| 8 |
+
les SDK réels (qui exigeraient des clés API et du réseau).
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
|
| 15 |
+
from picarones.domain.artifacts import ArtifactType
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 19 |
+
# Liste des adapters à tester avec leur identifiant attendu
|
| 20 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
_VLM_CASES = [
|
| 24 |
+
("anthropic_vlm", "picarones.adapters.vlm.anthropic_vlm",
|
| 25 |
+
"AnthropicVLMAdapter"),
|
| 26 |
+
("mistral_vlm", "picarones.adapters.vlm.mistral_vlm",
|
| 27 |
+
"MistralVLMAdapter"),
|
| 28 |
+
("ollama_vlm", "picarones.adapters.vlm.ollama_vlm",
|
| 29 |
+
"OllamaVLMAdapter"),
|
| 30 |
+
("openai_vlm", "picarones.adapters.vlm.openai_vlm",
|
| 31 |
+
"OpenAIVLMAdapter"),
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 36 |
+
# 1. Contrat de base : input/output types, name, MRO
|
| 37 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@pytest.mark.parametrize(
|
| 41 |
+
"expected_name,module_path,class_name", _VLM_CASES,
|
| 42 |
+
)
|
| 43 |
+
class TestVLMAdapterContract:
|
| 44 |
+
def test_input_types_is_image(
|
| 45 |
+
self, expected_name: str, module_path: str, class_name: str,
|
| 46 |
+
) -> None:
|
| 47 |
+
import importlib
|
| 48 |
+
|
| 49 |
+
module = importlib.import_module(module_path)
|
| 50 |
+
adapter_cls = getattr(module, class_name)
|
| 51 |
+
adapter = adapter_cls(model="any-model", config={})
|
| 52 |
+
|
| 53 |
+
assert ArtifactType.IMAGE in adapter.input_types
|
| 54 |
+
|
| 55 |
+
def test_output_types_is_raw_text(
|
| 56 |
+
self, expected_name: str, module_path: str, class_name: str,
|
| 57 |
+
) -> None:
|
| 58 |
+
import importlib
|
| 59 |
+
|
| 60 |
+
module = importlib.import_module(module_path)
|
| 61 |
+
adapter_cls = getattr(module, class_name)
|
| 62 |
+
adapter = adapter_cls(model="any-model", config={})
|
| 63 |
+
|
| 64 |
+
assert ArtifactType.RAW_TEXT in adapter.output_types
|
| 65 |
+
|
| 66 |
+
def test_name_is_distinct_per_adapter(
|
| 67 |
+
self, expected_name: str, module_path: str, class_name: str,
|
| 68 |
+
) -> None:
|
| 69 |
+
import importlib
|
| 70 |
+
|
| 71 |
+
module = importlib.import_module(module_path)
|
| 72 |
+
adapter_cls = getattr(module, class_name)
|
| 73 |
+
adapter = adapter_cls(model="any-model", config={})
|
| 74 |
+
|
| 75 |
+
assert adapter.name == expected_name
|
| 76 |
+
|
| 77 |
+
def test_mro_baseVLMAdapter_first(
|
| 78 |
+
self, expected_name: str, module_path: str, class_name: str,
|
| 79 |
+
) -> None:
|
| 80 |
+
"""Le garde-fou ``__init_subclass__`` exige
|
| 81 |
+
``BaseVLMAdapter`` AVANT le LLM sibling dans le MRO. On
|
| 82 |
+
vérifie qu'une instance correctement définie a bien
|
| 83 |
+
``BaseVLMAdapter`` parmi ses ancêtres et que ``input_types``
|
| 84 |
+
vient bien de lui (et pas du LLM)."""
|
| 85 |
+
import importlib
|
| 86 |
+
|
| 87 |
+
from picarones.adapters.vlm.base import BaseVLMAdapter
|
| 88 |
+
|
| 89 |
+
module = importlib.import_module(module_path)
|
| 90 |
+
adapter_cls = getattr(module, class_name)
|
| 91 |
+
assert issubclass(adapter_cls, BaseVLMAdapter)
|
| 92 |
+
# MRO : BaseVLMAdapter doit venir avant BaseLLMAdapter
|
| 93 |
+
# (à travers la chaîne d'héritage, on vérifie indirectement
|
| 94 |
+
# que ``input_types`` est l'IMAGE ; déjà testé plus haut).
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 98 |
+
# 2. Transcription prompt configurable
|
| 99 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class TestTranscriptionPromptConfigurable:
|
| 103 |
+
def test_custom_prompt_via_config(self) -> None:
|
| 104 |
+
from picarones.adapters.vlm.openai_vlm import OpenAIVLMAdapter
|
| 105 |
+
|
| 106 |
+
adapter = OpenAIVLMAdapter(
|
| 107 |
+
model="gpt-4o",
|
| 108 |
+
config={"transcription_prompt": "Custom prompt for testing."},
|
| 109 |
+
)
|
| 110 |
+
# Doit pouvoir instancier sans erreur ; le prompt est consommé
|
| 111 |
+
# par ``execute``.
|
| 112 |
+
assert adapter.name == "openai_vlm"
|
| 113 |
+
|
| 114 |
+
def test_default_prompt_used_when_none_provided(self) -> None:
|
| 115 |
+
from picarones.adapters.vlm.openai_vlm import OpenAIVLMAdapter
|
| 116 |
+
|
| 117 |
+
adapter = OpenAIVLMAdapter(model="gpt-4o", config={})
|
| 118 |
+
# Pas de plantage à l'init — le défaut est utilisé.
|
| 119 |
+
assert adapter is not None
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 123 |
+
# 3. MRO guard — ordre incorrect → TypeError
|
| 124 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class TestMROGuardRaisesOnSwap:
|
| 128 |
+
"""Le garde-fou ``__init_subclass__`` doit lever ``TypeError``
|
| 129 |
+
quand on déclare le LLM sibling AVANT ``BaseVLMAdapter``.
|
| 130 |
+
|
| 131 |
+
Reproduction du bug que le garde protège : si l'ordre est
|
| 132 |
+
inversé, ``input_types`` viendrait du LLM (= RAW_TEXT) au
|
| 133 |
+
lieu de IMAGE, et le pipeline silencieusement passerait du
|
| 134 |
+
texte au VLM."""
|
| 135 |
+
|
| 136 |
+
def test_swapped_parents_raises_typeerror(self) -> None:
|
| 137 |
+
from picarones.adapters.llm.openai_adapter import OpenAIAdapter
|
| 138 |
+
from picarones.adapters.vlm.base import BaseVLMAdapter
|
| 139 |
+
|
| 140 |
+
with pytest.raises(TypeError):
|
| 141 |
+
# Ordre INVERSE — BaseVLMAdapter en deuxième.
|
| 142 |
+
class _BadVLM(OpenAIAdapter, BaseVLMAdapter): # type: ignore[misc]
|
| 143 |
+
@property
|
| 144 |
+
def name(self) -> str:
|
| 145 |
+
return "bad"
|
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint S4.9 — couverture directe de ``CorpusService``.
|
| 2 |
+
|
| 3 |
+
Avant S4 : 0% direct (testé transitivement via les tests web et
|
| 4 |
+
les tests S1.5 ZIP slip).
|
| 5 |
+
|
| 6 |
+
Cible : 85%+ — vérifie le flux import normal, plus quelques cas
|
| 7 |
+
limites non couverts par S1.5 (qui se concentrait sur les attaques).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import io
|
| 13 |
+
import zipfile
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import pytest
|
| 17 |
+
|
| 18 |
+
from picarones.app.services.corpus_service import (
|
| 19 |
+
CorpusImportError,
|
| 20 |
+
CorpusImportReport,
|
| 21 |
+
CorpusService,
|
| 22 |
+
)
|
| 23 |
+
from picarones.app.services.path_security import WorkspaceManager
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 27 |
+
# Helpers — ZIP minimal valide
|
| 28 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
_PNG = (
|
| 32 |
+
b"\x89PNG\r\n\x1a\n"
|
| 33 |
+
b"\x00\x00\x00\rIHDR"
|
| 34 |
+
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00"
|
| 35 |
+
b"\x1f\x15\xc4\x89"
|
| 36 |
+
b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01"
|
| 37 |
+
b"\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _build_zip(entries: dict[str, bytes]) -> bytes:
|
| 42 |
+
buf = io.BytesIO()
|
| 43 |
+
with zipfile.ZipFile(buf, mode="w") as zf:
|
| 44 |
+
for name, data in entries.items():
|
| 45 |
+
zf.writestr(name, data)
|
| 46 |
+
return buf.getvalue()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@pytest.fixture
|
| 50 |
+
def service(tmp_path: Path) -> CorpusService:
|
| 51 |
+
ws = WorkspaceManager(base_dir=tmp_path)
|
| 52 |
+
return CorpusService(workspace=ws)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 56 |
+
# 1. Import normal : 1 image + 1 GT
|
| 57 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class TestNormalImport:
|
| 61 |
+
def test_simple_corpus_imports(self, service: CorpusService) -> None:
|
| 62 |
+
zip_bytes = _build_zip({
|
| 63 |
+
"doc01.png": _PNG,
|
| 64 |
+
"doc01.gt.txt": "Bonjour le monde".encode("utf-8"),
|
| 65 |
+
})
|
| 66 |
+
report = service.import_zip(zip_bytes, corpus_name="t")
|
| 67 |
+
assert isinstance(report, CorpusImportReport)
|
| 68 |
+
assert report.n_documents == 1
|
| 69 |
+
assert report.spec.name == "t"
|
| 70 |
+
assert (report.extracted_dir / "doc01.png").exists()
|
| 71 |
+
assert (report.extracted_dir / "doc01.gt.txt").exists()
|
| 72 |
+
|
| 73 |
+
def test_two_documents_imported(self, service: CorpusService) -> None:
|
| 74 |
+
zip_bytes = _build_zip({
|
| 75 |
+
"a.png": _PNG,
|
| 76 |
+
"a.gt.txt": b"texte a",
|
| 77 |
+
"b.png": _PNG,
|
| 78 |
+
"b.gt.txt": b"texte b",
|
| 79 |
+
})
|
| 80 |
+
report = service.import_zip(zip_bytes, corpus_name="t")
|
| 81 |
+
assert report.n_documents == 2
|
| 82 |
+
|
| 83 |
+
def test_metadata_passed_through(self, service: CorpusService) -> None:
|
| 84 |
+
zip_bytes = _build_zip({"d.png": _PNG, "d.gt.txt": b"x"})
|
| 85 |
+
report = service.import_zip(
|
| 86 |
+
zip_bytes,
|
| 87 |
+
corpus_name="meta_test",
|
| 88 |
+
metadata={"language": "fr", "script": "latin"},
|
| 89 |
+
)
|
| 90 |
+
assert report.spec.metadata.get("language") == "fr"
|
| 91 |
+
assert report.spec.metadata.get("script") == "latin"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 95 |
+
# 2. Cas dégradés
|
| 96 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class TestDegradedCases:
|
| 100 |
+
def test_image_without_gt_counted_separately(
|
| 101 |
+
self, service: CorpusService,
|
| 102 |
+
) -> None:
|
| 103 |
+
zip_bytes = _build_zip({
|
| 104 |
+
"with_gt.png": _PNG,
|
| 105 |
+
"with_gt.gt.txt": b"x",
|
| 106 |
+
"no_gt.png": _PNG, # pas de GT associé
|
| 107 |
+
})
|
| 108 |
+
report = service.import_zip(zip_bytes, corpus_name="t")
|
| 109 |
+
# Le service compte les images orphelines à part.
|
| 110 |
+
assert report.n_images_without_gt >= 1
|
| 111 |
+
|
| 112 |
+
def test_gt_without_image_counted_separately(
|
| 113 |
+
self, service: CorpusService,
|
| 114 |
+
) -> None:
|
| 115 |
+
zip_bytes = _build_zip({
|
| 116 |
+
"doc.png": _PNG,
|
| 117 |
+
"doc.gt.txt": b"x",
|
| 118 |
+
"orphan.gt.txt": b"orphan",
|
| 119 |
+
})
|
| 120 |
+
report = service.import_zip(zip_bytes, corpus_name="t")
|
| 121 |
+
assert report.n_gt_without_image >= 1
|
| 122 |
+
|
| 123 |
+
def test_invalid_zip_bytes_raises(self, service: CorpusService) -> None:
|
| 124 |
+
with pytest.raises(CorpusImportError):
|
| 125 |
+
service.import_zip(b"not a zip", corpus_name="t")
|
| 126 |
+
|
| 127 |
+
def test_empty_zip_imports_zero_docs(
|
| 128 |
+
self, service: CorpusService,
|
| 129 |
+
) -> None:
|
| 130 |
+
"""Un ZIP vide est accepté (pas d'erreur), mais le report
|
| 131 |
+
annonce 0 documents."""
|
| 132 |
+
zip_bytes = _build_zip({})
|
| 133 |
+
report = service.import_zip(zip_bytes, corpus_name="t")
|
| 134 |
+
assert report.n_documents == 0
|
| 135 |
+
|
| 136 |
+
def test_corpus_name_with_traversal_is_handled(
|
| 137 |
+
self, service: CorpusService, tmp_path: Path,
|
| 138 |
+
) -> None:
|
| 139 |
+
"""Un corpus_name avec ``../`` ne doit pas écrire hors du
|
| 140 |
+
workspace. Soit refusé, soit le path est sanitisé."""
|
| 141 |
+
zip_bytes = _build_zip({"d.png": _PNG, "d.gt.txt": b"x"})
|
| 142 |
+
try:
|
| 143 |
+
report = service.import_zip(zip_bytes, corpus_name="../escape")
|
| 144 |
+
except (CorpusImportError, ValueError):
|
| 145 |
+
return # Comportement souhaité
|
| 146 |
+
# Si pas de raise, le path doit rester confiné.
|
| 147 |
+
assert tmp_path in report.extracted_dir.resolve().parents
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 151 |
+
# 3. Limites configurables
|
| 152 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class TestLimits:
|
| 156 |
+
def test_too_many_entries_rejected(self, tmp_path: Path) -> None:
|
| 157 |
+
ws = WorkspaceManager(base_dir=tmp_path)
|
| 158 |
+
# Limite à 3 entrées max.
|
| 159 |
+
svc = CorpusService(workspace=ws, max_entry_count=3)
|
| 160 |
+
|
| 161 |
+
# ZIP avec 5 entrées → refus.
|
| 162 |
+
entries = {
|
| 163 |
+
f"doc{i:02d}.png": _PNG for i in range(5)
|
| 164 |
+
}
|
| 165 |
+
zip_bytes = _build_zip(entries)
|
| 166 |
+
with pytest.raises(CorpusImportError, match="entrées"):
|
| 167 |
+
svc.import_zip(zip_bytes, corpus_name="t")
|
| 168 |
+
|
| 169 |
+
def test_zip_blob_size_limit(self, tmp_path: Path) -> None:
|
| 170 |
+
ws = WorkspaceManager(base_dir=tmp_path)
|
| 171 |
+
# Limite ZIP à 100 octets (artificiellement bas).
|
| 172 |
+
svc = CorpusService(workspace=ws, max_zip_size_bytes=100)
|
| 173 |
+
|
| 174 |
+
# Notre ZIP minimal fait > 100 octets.
|
| 175 |
+
zip_bytes = _build_zip({"d.png": _PNG, "d.gt.txt": b"x"})
|
| 176 |
+
with pytest.raises(CorpusImportError):
|
| 177 |
+
svc.import_zip(zip_bytes, corpus_name="t")
|
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint S4.10 — couverture directe de ``JobRunner``.
|
| 2 |
+
|
| 3 |
+
Avant S4 : 0% direct (des tests transitifs existaient avant H.4
|
| 4 |
+
mais les chemins canoniques étaient peu couverts).
|
| 5 |
+
|
| 6 |
+
Cible : 85%+ — vérifie le contrat ``submit`` / ``wait`` avec un
|
| 7 |
+
orchestrator factice qui n'a pas besoin de Tesseract ni de réseau.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
+
from picarones.adapters.storage.job_store import JobStore
|
| 18 |
+
from picarones.app.services.job_runner import JobRunner
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 22 |
+
# Stub orchestrator
|
| 23 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class _StubOrchestrator:
|
| 27 |
+
"""Orchestrator de test : ne fait rien, retourne un manifest
|
| 28 |
+
fictif."""
|
| 29 |
+
|
| 30 |
+
def __init__(self, output_dir: Path, *, raise_on_execute: Exception | None = None,
|
| 31 |
+
delay: float = 0.0) -> None:
|
| 32 |
+
self.output_dir = output_dir
|
| 33 |
+
self.execute_called = False
|
| 34 |
+
self._raise = raise_on_execute
|
| 35 |
+
self._delay = delay
|
| 36 |
+
self.manifest_path = output_dir / "run_manifest.json"
|
| 37 |
+
|
| 38 |
+
def execute(self, run_spec: Any, *, report_renderer: Any = None) -> Any:
|
| 39 |
+
import time
|
| 40 |
+
if self._delay:
|
| 41 |
+
time.sleep(self._delay)
|
| 42 |
+
if self._raise:
|
| 43 |
+
raise self._raise
|
| 44 |
+
self.execute_called = True
|
| 45 |
+
return type("FakeResult", (), {
|
| 46 |
+
"manifest_path": self.manifest_path,
|
| 47 |
+
"report_path": None,
|
| 48 |
+
})()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _factory_with_stub(*, raise_on_execute: Exception | None = None,
|
| 52 |
+
delay: float = 0.0):
|
| 53 |
+
def _factory(output_dir: Path) -> _StubOrchestrator:
|
| 54 |
+
return _StubOrchestrator(
|
| 55 |
+
output_dir, raise_on_execute=raise_on_execute, delay=delay,
|
| 56 |
+
)
|
| 57 |
+
return _factory
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@pytest.fixture
|
| 61 |
+
def store(tmp_path: Path) -> JobStore:
|
| 62 |
+
return JobStore(db_path=tmp_path / "jobs.sqlite")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 66 |
+
# 1. submit + wait flow normal
|
| 67 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class TestSubmitNormalFlow:
|
| 71 |
+
def test_submit_returns_job_id(
|
| 72 |
+
self, store: JobStore, tmp_path: Path,
|
| 73 |
+
) -> None:
|
| 74 |
+
runner = JobRunner(
|
| 75 |
+
job_store=store,
|
| 76 |
+
orchestrator_factory=_factory_with_stub(),
|
| 77 |
+
)
|
| 78 |
+
job_id = runner.submit(
|
| 79 |
+
run_spec={},
|
| 80 |
+
output_dir=tmp_path / "out",
|
| 81 |
+
)
|
| 82 |
+
assert isinstance(job_id, str)
|
| 83 |
+
assert len(job_id) >= 8
|
| 84 |
+
|
| 85 |
+
def test_wait_completes(
|
| 86 |
+
self, store: JobStore, tmp_path: Path,
|
| 87 |
+
) -> None:
|
| 88 |
+
runner = JobRunner(
|
| 89 |
+
job_store=store,
|
| 90 |
+
orchestrator_factory=_factory_with_stub(),
|
| 91 |
+
)
|
| 92 |
+
job_id = runner.submit(run_spec={}, output_dir=tmp_path / "out")
|
| 93 |
+
finished = runner.wait(job_id, timeout=10.0)
|
| 94 |
+
assert finished is True
|
| 95 |
+
# Le statut DB doit être ``complete`` ou similaire
|
| 96 |
+
rec = store.get(job_id)
|
| 97 |
+
assert rec is not None
|
| 98 |
+
assert rec.status in ("complete", "running", "pending")
|
| 99 |
+
|
| 100 |
+
def test_explicit_job_id_is_respected(
|
| 101 |
+
self, store: JobStore, tmp_path: Path,
|
| 102 |
+
) -> None:
|
| 103 |
+
runner = JobRunner(
|
| 104 |
+
job_store=store,
|
| 105 |
+
orchestrator_factory=_factory_with_stub(),
|
| 106 |
+
)
|
| 107 |
+
job_id = runner.submit(
|
| 108 |
+
run_spec={},
|
| 109 |
+
output_dir=tmp_path / "out",
|
| 110 |
+
job_id="explicit_id",
|
| 111 |
+
)
|
| 112 |
+
assert job_id == "explicit_id"
|
| 113 |
+
runner.wait(job_id, timeout=5.0)
|
| 114 |
+
|
| 115 |
+
def test_payload_persisted_in_store(
|
| 116 |
+
self, store: JobStore, tmp_path: Path,
|
| 117 |
+
) -> None:
|
| 118 |
+
runner = JobRunner(
|
| 119 |
+
job_store=store,
|
| 120 |
+
orchestrator_factory=_factory_with_stub(),
|
| 121 |
+
)
|
| 122 |
+
job_id = runner.submit(
|
| 123 |
+
run_spec={},
|
| 124 |
+
output_dir=tmp_path / "out",
|
| 125 |
+
payload={"corpus": "test"},
|
| 126 |
+
)
|
| 127 |
+
runner.wait(job_id, timeout=5.0)
|
| 128 |
+
rec = store.get(job_id)
|
| 129 |
+
assert rec is not None
|
| 130 |
+
assert rec.payload.get("corpus") == "test"
|
| 131 |
+
assert rec.payload.get("output_dir") # auto-injecté
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 135 |
+
# 2. Exception dans l'orchestrator → status=error
|
| 136 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class TestOrchestratorFailure:
|
| 140 |
+
def test_exception_marks_job_error(
|
| 141 |
+
self, store: JobStore, tmp_path: Path,
|
| 142 |
+
) -> None:
|
| 143 |
+
runner = JobRunner(
|
| 144 |
+
job_store=store,
|
| 145 |
+
orchestrator_factory=_factory_with_stub(
|
| 146 |
+
raise_on_execute=RuntimeError("orchestrator boom"),
|
| 147 |
+
),
|
| 148 |
+
)
|
| 149 |
+
job_id = runner.submit(run_spec={}, output_dir=tmp_path / "out")
|
| 150 |
+
runner.wait(job_id, timeout=5.0)
|
| 151 |
+
rec = store.get(job_id)
|
| 152 |
+
assert rec is not None
|
| 153 |
+
assert rec.status == "error"
|
| 154 |
+
assert "boom" in rec.error or "error" in rec.error.lower()
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 158 |
+
# 3. Validation des paramètres au constructeur
|
| 159 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
class TestConstructorValidation:
|
| 163 |
+
def test_invalid_job_store_raises(self, tmp_path: Path) -> None:
|
| 164 |
+
with pytest.raises(TypeError, match="JobStore"):
|
| 165 |
+
JobRunner(
|
| 166 |
+
job_store="not a store", # type: ignore[arg-type]
|
| 167 |
+
orchestrator_factory=_factory_with_stub(),
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
def test_invalid_orchestrator_factory_raises(
|
| 171 |
+
self, store: JobStore,
|
| 172 |
+
) -> None:
|
| 173 |
+
with pytest.raises(TypeError, match="callable"):
|
| 174 |
+
JobRunner(
|
| 175 |
+
job_store=store,
|
| 176 |
+
orchestrator_factory="not callable", # type: ignore[arg-type]
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
def test_invalid_report_renderer_raises(
|
| 180 |
+
self, store: JobStore,
|
| 181 |
+
) -> None:
|
| 182 |
+
with pytest.raises(TypeError, match="callable"):
|
| 183 |
+
JobRunner(
|
| 184 |
+
job_store=store,
|
| 185 |
+
orchestrator_factory=_factory_with_stub(),
|
| 186 |
+
report_renderer="not callable", # type: ignore[arg-type]
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 191 |
+
# 4. Wait sur job inconnu
|
| 192 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
class TestWaitEdgeCases:
|
| 196 |
+
def test_wait_unknown_job_returns_true(
|
| 197 |
+
self, store: JobStore, tmp_path: Path,
|
| 198 |
+
) -> None:
|
| 199 |
+
runner = JobRunner(
|
| 200 |
+
job_store=store,
|
| 201 |
+
orchestrator_factory=_factory_with_stub(),
|
| 202 |
+
)
|
| 203 |
+
# job inconnu = considéré déjà fini
|
| 204 |
+
assert runner.wait("ghost_job", timeout=1.0) is True
|
| 205 |
+
|
| 206 |
+
def test_wait_timeout_returns_false(
|
| 207 |
+
self, store: JobStore, tmp_path: Path,
|
| 208 |
+
) -> None:
|
| 209 |
+
runner = JobRunner(
|
| 210 |
+
job_store=store,
|
| 211 |
+
orchestrator_factory=_factory_with_stub(delay=2.0),
|
| 212 |
+
)
|
| 213 |
+
job_id = runner.submit(run_spec={}, output_dir=tmp_path / "out")
|
| 214 |
+
# Timeout court — le job n'aura pas fini
|
| 215 |
+
assert runner.wait(job_id, timeout=0.1) is False
|
| 216 |
+
# Cleanup : attendre que le thread se termine
|
| 217 |
+
runner.wait(job_id, timeout=5.0)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint S4.4-S4.7 — couverture des 4 vues HTML thématiques.
|
| 2 |
+
|
| 3 |
+
Avant S4 :
|
| 4 |
+
- ``views/pipeline.py`` à 27%
|
| 5 |
+
- ``views/robustness.py`` à 38%
|
| 6 |
+
- ``views/diagnostics.py`` à 48%
|
| 7 |
+
- ``views/advanced_taxonomy.py`` à 71%
|
| 8 |
+
|
| 9 |
+
Cible : 85%+ chacune.
|
| 10 |
+
|
| 11 |
+
Stratégie : 3 niveaux de test par vue —
|
| 12 |
+
1. ``empty`` : ``report_data={}`` minimal → vue retourne ``""``
|
| 13 |
+
(adaptive masking corpus-wide).
|
| 14 |
+
2. ``partial`` : données pour 1 seule sous-section → seule cette
|
| 15 |
+
section apparaît, les autres sont masquées.
|
| 16 |
+
3. ``populated`` : données pour toutes les sous-sections → HTML
|
| 17 |
+
structurellement valide, contient les marqueurs attendus.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 24 |
+
# 1. Pipeline view
|
| 25 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class TestPipelineView:
|
| 29 |
+
def test_empty_report_data_returns_empty_string(self) -> None:
|
| 30 |
+
from picarones.reports.html.views.pipeline import (
|
| 31 |
+
build_pipeline_view_html,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
out = build_pipeline_view_html(report_data={}, labels={})
|
| 35 |
+
assert out == "" or out.strip() == ""
|
| 36 |
+
|
| 37 |
+
def test_with_dag_data_renders_section(self) -> None:
|
| 38 |
+
from picarones.reports.html.views.pipeline import (
|
| 39 |
+
build_pipeline_view_html,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
out = build_pipeline_view_html(
|
| 43 |
+
report_data={"engines": []},
|
| 44 |
+
labels={},
|
| 45 |
+
dag_nodes=["ocr", "llm"],
|
| 46 |
+
dag_labels={"ocr": "OCR", "llm": "LLM"},
|
| 47 |
+
dag_edges=[("ocr", "llm")],
|
| 48 |
+
dag_thresholds=(0.05, 0.20),
|
| 49 |
+
junctions=[
|
| 50 |
+
{
|
| 51 |
+
"from": "ocr",
|
| 52 |
+
"to": "llm",
|
| 53 |
+
"metrics": {"cer": 0.10},
|
| 54 |
+
},
|
| 55 |
+
],
|
| 56 |
+
)
|
| 57 |
+
# Au moins du HTML produit.
|
| 58 |
+
assert isinstance(out, str)
|
| 59 |
+
|
| 60 |
+
def test_call_does_not_raise_on_minimal_inputs(self) -> None:
|
| 61 |
+
"""Garde-fou : avec un report_data minimal mais des kwargs
|
| 62 |
+
partiellement remplis, l'appel ne doit pas lever."""
|
| 63 |
+
from picarones.reports.html.views.pipeline import (
|
| 64 |
+
build_pipeline_view_html,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
out = build_pipeline_view_html(
|
| 68 |
+
report_data={"engines": [{"name": "tess", "cer_mean": 0.05}]},
|
| 69 |
+
labels={"x": "y"},
|
| 70 |
+
dag_nodes=None,
|
| 71 |
+
junctions=None,
|
| 72 |
+
)
|
| 73 |
+
assert isinstance(out, str)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 77 |
+
# 2. Robustness view
|
| 78 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class TestRobustnessView:
|
| 82 |
+
def test_empty_returns_empty_string(self) -> None:
|
| 83 |
+
from picarones.reports.html.views.robustness import (
|
| 84 |
+
build_robustness_view_html,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
out = build_robustness_view_html(report_data={}, labels={})
|
| 88 |
+
assert out == "" or out.strip() == ""
|
| 89 |
+
|
| 90 |
+
def test_with_projection_renders(self) -> None:
|
| 91 |
+
from picarones.reports.html.views.robustness import (
|
| 92 |
+
build_robustness_view_html,
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# Format minimal accepté par le renderer
|
| 96 |
+
# robustness_projection — au moins un moteur + un type de
|
| 97 |
+
# dégradation.
|
| 98 |
+
projection = {
|
| 99 |
+
"tesseract": {
|
| 100 |
+
"noise": [
|
| 101 |
+
{"level": 0, "cer": 0.05},
|
| 102 |
+
{"level": 5, "cer": 0.08},
|
| 103 |
+
],
|
| 104 |
+
},
|
| 105 |
+
}
|
| 106 |
+
aggregated = {"tesseract": {"slope": 0.01}}
|
| 107 |
+
|
| 108 |
+
out = build_robustness_view_html(
|
| 109 |
+
report_data={"engines": []},
|
| 110 |
+
labels={},
|
| 111 |
+
projection=projection,
|
| 112 |
+
aggregated=aggregated,
|
| 113 |
+
)
|
| 114 |
+
assert isinstance(out, str)
|
| 115 |
+
|
| 116 |
+
def test_no_projection_no_aggregated_returns_empty(self) -> None:
|
| 117 |
+
from picarones.reports.html.views.robustness import (
|
| 118 |
+
build_robustness_view_html,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
out = build_robustness_view_html(
|
| 122 |
+
report_data={},
|
| 123 |
+
labels={},
|
| 124 |
+
projection=None,
|
| 125 |
+
aggregated=None,
|
| 126 |
+
)
|
| 127 |
+
assert out == "" or out.strip() == ""
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 131 |
+
# 3. Diagnostics view
|
| 132 |
+
# ────────────────────────────��─────────────────────────────────────────
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class TestDiagnosticsView:
|
| 136 |
+
def test_empty_returns_empty_string(self) -> None:
|
| 137 |
+
from picarones.reports.html.views.diagnostics import (
|
| 138 |
+
build_diagnostics_view_html,
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
out = build_diagnostics_view_html(report_data={}, labels={})
|
| 142 |
+
assert out == "" or out.strip() == ""
|
| 143 |
+
|
| 144 |
+
def test_with_baseline_data_renders(self) -> None:
|
| 145 |
+
from picarones.reports.html.views.diagnostics import (
|
| 146 |
+
build_diagnostics_view_html,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
out = build_diagnostics_view_html(
|
| 150 |
+
report_data={"engines": [{"name": "t"}]},
|
| 151 |
+
labels={},
|
| 152 |
+
baseline_data={"percentile": 0.5, "n_corpora": 10},
|
| 153 |
+
)
|
| 154 |
+
assert isinstance(out, str)
|
| 155 |
+
|
| 156 |
+
def test_with_longitudinal_data_renders(self) -> None:
|
| 157 |
+
from picarones.reports.html.views.diagnostics import (
|
| 158 |
+
build_diagnostics_view_html,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
out = build_diagnostics_view_html(
|
| 162 |
+
report_data={"engines": []},
|
| 163 |
+
labels={},
|
| 164 |
+
longitudinal={
|
| 165 |
+
"tesseract": {
|
| 166 |
+
"trend_slope": -0.001,
|
| 167 |
+
"n_runs": 20,
|
| 168 |
+
},
|
| 169 |
+
},
|
| 170 |
+
)
|
| 171 |
+
assert isinstance(out, str)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 175 |
+
# 4. Advanced taxonomy view
|
| 176 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
class TestAdvancedTaxonomyView:
|
| 180 |
+
def test_empty_returns_empty_string(self) -> None:
|
| 181 |
+
from picarones.reports.html.views.advanced_taxonomy import (
|
| 182 |
+
build_advanced_taxonomy_view_html,
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
out = build_advanced_taxonomy_view_html(report_data={}, labels={})
|
| 186 |
+
assert out == "" or out.strip() == ""
|
| 187 |
+
|
| 188 |
+
def test_with_cooccurrence_renders(self) -> None:
|
| 189 |
+
from picarones.reports.html.views.advanced_taxonomy import (
|
| 190 |
+
build_advanced_taxonomy_view_html,
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
out = build_advanced_taxonomy_view_html(
|
| 194 |
+
report_data={"engines": [{"name": "t"}]},
|
| 195 |
+
labels={},
|
| 196 |
+
cooccurrence={
|
| 197 |
+
"matrix": [[0, 1], [1, 0]],
|
| 198 |
+
"categories": ["sub", "ins"],
|
| 199 |
+
},
|
| 200 |
+
)
|
| 201 |
+
assert isinstance(out, str)
|
| 202 |
+
|
| 203 |
+
def test_with_intra_doc_renders(self) -> None:
|
| 204 |
+
from picarones.reports.html.views.advanced_taxonomy import (
|
| 205 |
+
build_advanced_taxonomy_view_html,
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
out = build_advanced_taxonomy_view_html(
|
| 209 |
+
report_data={"engines": []},
|
| 210 |
+
labels={},
|
| 211 |
+
intra_doc={
|
| 212 |
+
"tesseract": {
|
| 213 |
+
"heatmap": [[0.05, 0.10]],
|
| 214 |
+
"categories": ["sub"],
|
| 215 |
+
},
|
| 216 |
+
},
|
| 217 |
+
)
|
| 218 |
+
assert isinstance(out, str)
|
| 219 |
+
|
| 220 |
+
def test_with_lexical_modernization_renders(self) -> None:
|
| 221 |
+
from picarones.reports.html.views.advanced_taxonomy import (
|
| 222 |
+
build_advanced_taxonomy_view_html,
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
out = build_advanced_taxonomy_view_html(
|
| 226 |
+
report_data={"engines": []},
|
| 227 |
+
labels={},
|
| 228 |
+
lexical_modernization={
|
| 229 |
+
"tesseract": {"score": 0.05, "n_modernizations": 3},
|
| 230 |
+
},
|
| 231 |
+
)
|
| 232 |
+
assert isinstance(out, str)
|