Spaces:
Running
Running
| """Tests du chantier 4 (post-Sprint 97) : LLM + Gallica/IIIF + CLI workflows. | |
| Couvre : | |
| - Sous-chantier 4.A : ``normalize_llm_content`` + ``log_http_error`` | |
| factorisΓ©s dans :mod:`picarones.llm.base`, propagΓ©s aux 4 adapters. | |
| - Sous-chantier 4.B : helpers HTTP factorisΓ©s dans | |
| :mod:`picarones.adapters.corpus._http`, Gallica et IIIF y délèguent. | |
| - Sous-chantier 4.C : 3 nouvelles sous-commandes CLI ``diagnose``, | |
| ``economics``, ``edition`` qui mappent un profil de calcul | |
| (chantier 2) Γ un workflow. | |
| """ | |
| from __future__ import annotations | |
| import pytest | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 4.A β LLM base helpers | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestNormalizeLlmContent: | |
| def test_str_passes_through(self): | |
| from picarones.adapters.llm.base import normalize_llm_content | |
| assert normalize_llm_content("hello") == "hello" | |
| # Idempotence : retourne l'objet exact pour str | |
| s = "test" | |
| assert normalize_llm_content(s) is s | |
| def test_none_returns_empty(self): | |
| from picarones.adapters.llm.base import normalize_llm_content | |
| assert normalize_llm_content(None) == "" | |
| def test_empty_string_passes(self): | |
| from picarones.adapters.llm.base import normalize_llm_content | |
| assert normalize_llm_content("") == "" | |
| def test_list_of_chunks_with_text_attr(self): | |
| """Cas Mistral SDK : list[ContentChunk]. Sprint 15 fix.""" | |
| from picarones.adapters.llm.base import normalize_llm_content | |
| class MockChunk: | |
| def __init__(self, text): | |
| self.text = text | |
| result = normalize_llm_content([MockChunk("hello "), MockChunk("world")]) | |
| assert result == "hello world" | |
| def test_list_of_dicts_with_text_key(self): | |
| """Cas Anthropic SDK : list[dict] avec clΓ© 'text'.""" | |
| from picarones.adapters.llm.base import normalize_llm_content | |
| result = normalize_llm_content([{"text": "a"}, {"text": "b"}]) | |
| assert result == "ab" | |
| def test_list_of_strings(self): | |
| from picarones.adapters.llm.base import normalize_llm_content | |
| assert normalize_llm_content(["foo", "bar"]) == "foobar" | |
| def test_mixed_list(self): | |
| from picarones.adapters.llm.base import normalize_llm_content | |
| class MockChunk: | |
| def __init__(self, text): | |
| self.text = text | |
| result = normalize_llm_content([ | |
| MockChunk("a"), "b", {"text": "c"}, | |
| ]) | |
| assert result == "abc" | |
| def test_none_in_list_skipped(self): | |
| from picarones.adapters.llm.base import normalize_llm_content | |
| assert normalize_llm_content([None, "a", None, "b"]) == "ab" | |
| def test_object_with_text_attribute(self): | |
| from picarones.adapters.llm.base import normalize_llm_content | |
| class TextHolder: | |
| text = "hello" | |
| assert normalize_llm_content(TextHolder()) == "hello" | |
| class TestLogHttpError: | |
| def test_401_logs_invalid_key(self, caplog): | |
| from picarones.adapters.llm.base import log_http_error | |
| class FakeExc(Exception): | |
| status_code = 401 | |
| with caplog.at_level("WARNING"): | |
| log_http_error("OpenAIAdapter", "gpt-4o", FakeExc("Unauthorized"), | |
| env_var="OPENAI_API_KEY") | |
| assert any("401" in r.message and "OPENAI_API_KEY" in r.message | |
| for r in caplog.records) | |
| def test_429_logs_rate_limit(self, caplog): | |
| from picarones.adapters.llm.base import log_http_error | |
| class FakeExc(Exception): | |
| status_code = 429 | |
| with caplog.at_level("WARNING"): | |
| log_http_error("MistralAdapter", "mistral-large", FakeExc("Too Many")) | |
| assert any("429" in r.message and "rate" in r.message.lower() | |
| for r in caplog.records) | |
| def test_5xx_logs_server_error(self, caplog): | |
| from picarones.adapters.llm.base import log_http_error | |
| class FakeExc(Exception): | |
| status_code = 503 | |
| with caplog.at_level("WARNING"): | |
| log_http_error("AnthropicAdapter", "claude-sonnet", FakeExc("Service unavailable")) | |
| assert any("503" in r.message and "serveur" in r.message.lower() | |
| for r in caplog.records) | |
| def test_no_status_code_logs_generic(self, caplog): | |
| from picarones.adapters.llm.base import log_http_error | |
| with caplog.at_level("WARNING"): | |
| log_http_error("Foo", "bar", ValueError("random")) | |
| # Doit produire un warning (gΓ©nΓ©rique) | |
| assert any("Foo" in r.message for r in caplog.records) | |
| class TestLlmAdaptersInheritEnvVar: | |
| """Le chantier 4 a ajoutΓ© ``api_key_env_var`` aux 3 adapters cloud.""" | |
| def test_mistral_declares_env_var(self): | |
| from picarones.adapters.llm.mistral_adapter import MistralAdapter | |
| assert MistralAdapter.api_key_env_var == "MISTRAL_API_KEY" | |
| def test_openai_declares_env_var(self): | |
| from picarones.adapters.llm.openai_adapter import OpenAIAdapter | |
| assert OpenAIAdapter.api_key_env_var == "OPENAI_API_KEY" | |
| def test_anthropic_declares_env_var(self): | |
| from picarones.adapters.llm.anthropic_adapter import AnthropicAdapter | |
| assert AnthropicAdapter.api_key_env_var == "ANTHROPIC_API_KEY" | |
| def test_ollama_no_env_var(self): | |
| """Ollama est local β pas de clΓ© API.""" | |
| from picarones.adapters.llm.ollama_adapter import OllamaAdapter | |
| assert OllamaAdapter.api_key_env_var is None | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 4.B β Helpers HTTP factorisΓ©s (Gallica β IIIF fusion) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestHttpHelpers: | |
| def test_validate_http_url_accepts_https(self): | |
| from picarones.adapters.corpus._http import validate_http_url | |
| validate_http_url("https://gallica.bnf.fr/test") # ne lève pas | |
| def test_validate_http_url_accepts_http(self): | |
| from picarones.adapters.corpus._http import validate_http_url | |
| validate_http_url("http://localhost:8080/x") | |
| def test_validate_http_url_rejects_other_schemes(self, scheme): | |
| from picarones.adapters.corpus._http import validate_http_url | |
| with pytest.raises(ValueError, match="non autorisΓ©"): | |
| validate_http_url(f"{scheme}://example.com/x") | |
| class TestIiifAliasesDelegateToHttp: | |
| """Les noms ``_validate_url`` et ``_download_url`` exposΓ©s depuis | |
| :mod:`picarones.adapters.corpus.iiif` doivent rester disponibles | |
| (rΓ©trocompat des tests Sprint 4) β ils dΓ©lΓ¨guent aux helpers | |
| factorisΓ©s.""" | |
| def test_iiif_validate_url_is_alias(self): | |
| from picarones.adapters.corpus import iiif | |
| from picarones.adapters.corpus._http import validate_http_url | |
| assert iiif._validate_url is validate_http_url | |
| def test_iiif_download_url_is_alias(self): | |
| from picarones.adapters.corpus import iiif | |
| from picarones.adapters.corpus._http import download_url | |
| assert iiif._download_url is download_url | |
| class TestGallicaDelegatesToHttp: | |
| def test_gallica_validate_url_delegates(self): | |
| from picarones.adapters.corpus.gallica import GallicaClient | |
| client = GallicaClient() | |
| # Doit accepter https | |
| client._validate_url("https://gallica.bnf.fr/x") | |
| # Doit rejeter un schΓ©ma invalide via le helper factorisΓ© | |
| with pytest.raises(ValueError, match="non autorisΓ©"): | |
| client._validate_url("file:///etc/passwd") | |
| def test_gallica_uses_iiif_for_image_download(self): | |
| """``GallicaClient.import_document`` délègue à IIIFImporter.""" | |
| # Lecture statique du source β pas d'appel rΓ©seau. | |
| # Phase 8 (post-Chantier 5) : le contenu vit dΓ©sormais dans | |
| # ``picarones/adapters/corpus/gallica.py`` (canonique). | |
| from pathlib import Path | |
| gallica_src = ( | |
| Path(__file__).parent.parent.parent | |
| / "picarones" / "adapters" / "corpus" / "gallica.py" | |
| ).read_text(encoding="utf-8") | |
| # Confirme que Gallica importe IIIFImporter | |
| assert "IIIFImporter" in gallica_src | |
| assert "from picarones.adapters.corpus.iiif" in gallica_src | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 4.C β Workflows CLI dΓ©diΓ©s | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestCliWorkflows: | |
| def test_three_new_commands_registered(self): | |
| from pathlib import Path | |
| cli_src = ( | |
| Path(__file__).parent.parent.parent / "picarones" / "interfaces" / "cli" / "_legacy" / "_workflows.py" | |
| ).read_text(encoding="utf-8") | |
| # VΓ©rification statique : les 3 commandes existent | |
| assert '@cli.command("diagnose")' in cli_src | |
| assert '@cli.command("economics")' in cli_src | |
| assert '@cli.command("edition")' in cli_src | |
| assert "def diagnose_cmd(" in cli_src | |
| assert "def economics_cmd(" in cli_src | |
| assert "def edition_cmd(" in cli_src | |
| def test_workflows_map_correct_profile(self): | |
| from pathlib import Path | |
| cli_src = ( | |
| Path(__file__).parent.parent.parent / "picarones" / "interfaces" / "cli" / "_legacy" / "_workflows.py" | |
| ).read_text(encoding="utf-8") | |
| # Chaque commande doit fixer le bon profil | |
| # diagnose β diagnostics, economics β economics, edition β philological | |
| assert 'profile="diagnostics"' in cli_src | |
| assert 'profile="economics"' in cli_src | |
| assert 'profile="philological"' in cli_src | |
| def test_run_workflow_helper_exists(self): | |
| """Le helper commun ``_run_workflow`` factorise la logique des | |
| 4 commandes (run + diagnose + economics + edition) β un seul | |
| endroit pour patcher si la logique Γ©volue.""" | |
| import ast | |
| from pathlib import Path | |
| cli_src = ( | |
| Path(__file__).parent.parent.parent / "picarones" / "interfaces" / "cli" / "_legacy" / "_workflows.py" | |
| ).read_text(encoding="utf-8") | |
| tree = ast.parse(cli_src) | |
| funcs = { | |
| n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) | |
| } | |
| assert "_run_workflow" in funcs | |
| def test_command_help_works(self, cmd_name): | |
| """Les 3 commandes rΓ©pondent Γ --help sans crash.""" | |
| try: | |
| from click.testing import CliRunner | |
| from picarones.interfaces.cli._legacy import cli as cli_group | |
| except ImportError: | |
| pytest.skip("click non installΓ©") | |
| runner = CliRunner() | |
| result = runner.invoke(cli_group, [cmd_name, "--help"]) | |
| assert result.exit_code == 0, result.output | |
| assert "--corpus" in result.output | |
| assert "--engines" in result.output | |