Spaces:
Sleeping
Sleeping
Claude
feat(sprint-S4-batch1+S5): coverage modules critiques + tests dégradation réseau
9e46e55 unverified | """Sprint S5 — Simulation de disque plein (ENOSPC). | |
| Vérifie la robustesse du chemin "écriture sur disque" face à un | |
| ``OSError(28, 'No space left on device')``. | |
| Cas couverts : | |
| - ``partial_store._save_partial_line`` doit logger un warning et NE | |
| PAS lever (le benchmark continue, on ne casse pas tout pour une | |
| ligne perdue). | |
| - ``BenchmarkResult.to_json`` doit propager l'OSError (l'utilisateur | |
| veut savoir que le rapport n'a pas pu être écrit). | |
| - Aucun fichier corrompu / partiel n'est laissé. | |
| """ | |
| from __future__ import annotations | |
| import errno | |
| import os | |
| import json | |
| from pathlib import Path | |
| from unittest.mock import patch | |
| import pytest | |
| def _enospc_oserror(): | |
| """Construit un OSError(ENOSPC) prêt à utiliser comme side_effect.""" | |
| return OSError(errno.ENOSPC, os.strerror(errno.ENOSPC)) | |
| # -------------------------------------------------------------------------- | |
| # 1. partial_store._save_partial_line absorbe ENOSPC | |
| # -------------------------------------------------------------------------- | |
| class TestPartialStoreEnospcAbsorbed: | |
| """Quand le disque est plein, on ne veut pas casser un | |
| benchmark de 1000 docs juste parce que le partial_dir est full : | |
| ``_save_partial_line`` log warning et retourne.""" | |
| def test_save_partial_line_enospc_logs_warning_no_raise( | |
| self, tmp_path, caplog, | |
| ): | |
| from picarones.app.services.partial_store import _save_partial_line | |
| from picarones.evaluation.benchmark_result import DocumentResult | |
| from picarones.evaluation.metric_result import MetricsResult | |
| partial_path = tmp_path / "p.partial.jsonl" | |
| doc = DocumentResult( | |
| doc_id="d1", | |
| image_path="/a/b.jpg", | |
| ground_truth="x", | |
| hypothesis="x", | |
| metrics=MetricsResult(reference_length=1, hypothesis_length=1), | |
| duration_seconds=0.1, | |
| ) | |
| # Patch ``open`` pour lever ENOSPC à l'ouverture en append. | |
| original_open = Path.open | |
| def _open_with_enospc(self, mode="r", *args, **kwargs): | |
| if "a" in mode and self == partial_path: | |
| raise _enospc_oserror() | |
| return original_open(self, mode, *args, **kwargs) | |
| with patch.object(Path, "open", _open_with_enospc): | |
| with caplog.at_level("WARNING"): | |
| # Ne doit PAS lever | |
| _save_partial_line(partial_path, doc) | |
| # Le warning a été loggé | |
| assert any( | |
| "partial_dir" in rec.message or "impossible" in rec.message.lower() | |
| for rec in caplog.records | |
| ) | |
| # Aucun fichier partiel n'a été créé (open a échoué avant écriture) | |
| assert not partial_path.exists() | |
| # -------------------------------------------------------------------------- | |
| # 2. _delete_partial absorbe ENOSPC | |
| # -------------------------------------------------------------------------- | |
| class TestDeletePartialEnospcAbsorbed: | |
| def test_delete_partial_oserror_logs_warning(self, tmp_path, caplog): | |
| from picarones.app.services.partial_store import _delete_partial | |
| # Créer un fichier réel | |
| partial_path = tmp_path / "p.partial.jsonl" | |
| partial_path.write_text('{"doc_id": "x"}\n', encoding="utf-8") | |
| with patch.object(Path, "unlink", side_effect=_enospc_oserror()): | |
| with caplog.at_level("WARNING"): | |
| # Ne lève pas | |
| _delete_partial(partial_path) | |
| # Le warning est loggé | |
| assert any( | |
| "partial_dir" in rec.message or "impossible" in rec.message.lower() | |
| for rec in caplog.records | |
| ) | |
| # -------------------------------------------------------------------------- | |
| # 3. BenchmarkResult.to_json sur disque plein | |
| # -------------------------------------------------------------------------- | |
| class TestBenchmarkResultToJsonEnospc: | |
| """``to_json`` ouvre un fichier et écrit en JSON. Sur ENOSPC, | |
| on doit propager l'OSError (l'utilisateur veut le savoir, le | |
| rapport est critique). Et aucun fichier corrompu ne doit | |
| rester sur disque (le file handler ferme automatiquement, mais | |
| on vérifie qu'aucun .json tronqué ne pollue le résultat). | |
| """ | |
| def test_to_json_enospc_propagates_and_no_garbage(self, tmp_path): | |
| from picarones.evaluation.benchmark_result import ( | |
| BenchmarkResult, | |
| EngineReport, | |
| DocumentResult, | |
| ) | |
| from picarones.evaluation.metric_result import MetricsResult | |
| dr = DocumentResult( | |
| doc_id="d1", | |
| image_path="/a/b.jpg", | |
| ground_truth="x", | |
| hypothesis="x", | |
| metrics=MetricsResult(reference_length=1, hypothesis_length=1), | |
| duration_seconds=0.1, | |
| ) | |
| report = EngineReport( | |
| engine_name="e", | |
| engine_version="1", | |
| engine_config={}, | |
| document_results=[dr], | |
| ) | |
| bench = BenchmarkResult( | |
| corpus_name="c", | |
| corpus_source=None, | |
| document_count=1, | |
| engine_reports=[report], | |
| ) | |
| out = tmp_path / "rapport.json" | |
| # Patch json.dump pour lever ENOSPC pendant l'écriture | |
| # (simule un disque qui se remplit pendant l'écriture). | |
| with patch( | |
| "picarones.evaluation.benchmark_result.json.dump", | |
| side_effect=_enospc_oserror(), | |
| ): | |
| with pytest.raises(OSError) as exc_info: | |
| bench.to_json(out) | |
| assert exc_info.value.errno == errno.ENOSPC | |
| # Le fichier a pu être créé (ouverture en mode "w" précède dump) | |
| # mais s'il existe il doit être vide (aucune ligne JSON valide). | |
| if out.exists(): | |
| content = out.read_text(encoding="utf-8") | |
| # Pas de JSON tronqué : soit vide, soit explicitement | |
| # incomplet. On ne tolère pas un demi-objet. | |
| if content: | |
| # Doit être impossible de parser comme JSON valide | |
| with pytest.raises(json.JSONDecodeError): | |
| json.loads(content) | |
| # -------------------------------------------------------------------------- | |
| # 4. Idempotence du delete_partial absent | |
| # -------------------------------------------------------------------------- | |
| class TestDeletePartialAbsent: | |
| """Si le fichier n'existe pas, ``_delete_partial`` est un no-op | |
| silencieux (pas de FileNotFoundError, pas de warning).""" | |
| def test_delete_nonexistent_partial_silent_noop(self, tmp_path, caplog): | |
| from picarones.app.services.partial_store import _delete_partial | |
| nonexistent = tmp_path / "absent.partial.jsonl" | |
| assert not nonexistent.exists() | |
| with caplog.at_level("WARNING"): | |
| _delete_partial(nonexistent) | |
| # Pas de warning : c'est un no-op silencieux par contrat | |
| warnings = [ | |
| r for r in caplog.records | |
| if r.levelname == "WARNING" | |
| ] | |
| assert warnings == [] | |