Refacto du code coté Python, java et coté angular afin de mieux séparer les responsabilité et d'avoir moins de répétitivité dans le code.
Some checks failed
E2E Tests / e2e (push) Waiting to run
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 26s
Build & Push Images / tests (push) Failing after 13s
Build & Push Images / build (brain) (push) Has been skipped
Build & Push Images / build (core) (push) Has been skipped
Build & Push Images / build (web) (push) Has been skipped
Build & Push Images / build-switcher (push) Has been skipped
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 1m12s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 1m28s

Mise en place de tests unitaires coté Python et Angular
Mise en place de la couverture de test directement dans le workflow : le programme ne build pas si jamais un test échoue
Passage en v0.16.2 en conséquence
This commit is contained in:
2026-06-18 15:59:10 +02:00
parent eb78a75621
commit 4d049274f9
68 changed files with 4433 additions and 1360 deletions

View File

@@ -0,0 +1,72 @@
"""Tests du use case de conseils d'adaptation (app.application.adapt_campaign)."""
from __future__ import annotations
import pytest
from app.application.adapt_campaign import AdaptCampaignUseCase
from app.domain.models import ChatMessage, ExtractedDocument, ExtractedPage
from app.domain.ports import PdfExtractionError
class FakeExtractor:
def __init__(self, doc: ExtractedDocument) -> None:
self._doc = doc
def extract(self, pdf_bytes: bytes) -> ExtractedDocument:
return self._doc
class FakeChatLLM:
def __init__(self, tokens: list[str]) -> None:
self._tokens = tokens
self.system_prompt: str | None = None
self.messages: list[ChatMessage] | None = None
async def stream_chat(self, messages, *, system_prompt=None, temperature=None):
self.messages = messages
self.system_prompt = system_prompt
for t in self._tokens:
yield t
def _doc(text: str) -> ExtractedDocument:
return ExtractedDocument(pages=[ExtractedPage(index=0, text=text, used_ocr=False)])
async def test_stream_yields_tokens_and_builds_context():
llm = FakeChatLLM(["con", "seil"])
uc = AdaptCampaignUseCase(llm, FakeExtractor(_doc("contenu du pdf")))
out = [t async for t in uc.stream(b"x", "mon brief de campagne",
[ChatMessage(role="user", content="aide")])]
assert out == ["con", "seil"]
assert "mon brief de campagne" in llm.system_prompt
assert "contenu du pdf" in llm.system_prompt
async def test_stream_empty_pdf_text_raises():
uc = AdaptCampaignUseCase(FakeChatLLM([]), FakeExtractor(_doc(" ")))
with pytest.raises(PdfExtractionError):
[t async for t in uc.stream(b"x", "brief", [])]
async def test_stream_injects_default_request_when_no_messages():
llm = FakeChatLLM(["ok"])
uc = AdaptCampaignUseCase(llm, FakeExtractor(_doc("texte du pdf")))
_ = [t async for t in uc.stream(b"x", "", [])]
assert llm.messages[0].role == "user"
assert "campagne" in llm.messages[0].content.lower()
def test_fit_pdf_short_text_not_truncated():
uc = AdaptCampaignUseCase(None, None, max_input_tokens=10000)
text, truncated = uc._fit_pdf_to_budget("court texte", "brief")
assert truncated is False
assert text == "court texte"
def test_fit_pdf_long_text_is_truncated():
uc = AdaptCampaignUseCase(None, None, max_input_tokens=2100)
long_text = "mot " * 5000
text, truncated = uc._fit_pdf_to_budget(long_text, "")
assert truncated is True
assert len(text) < len(long_text)

129
brain/tests/test_chat.py Normal file
View File

@@ -0,0 +1,129 @@
"""Tests de la construction du system prompt du chat (app.application.chat).
Assertions par INCLUSION (présence des données/sections clés) plutôt que sur le
texte exact des consignes : robuste aux retouches de formulation, tout en
vérifiant que chaque contexte est bien injecté.
"""
from __future__ import annotations
from app.application.chat import ChatUseCase
from app.domain.models import (
ArcSummary,
CampaignStructuralContext,
ChapterSummary,
CharacterSummary,
ChatMessage,
GameSystemContext,
JournalEntrySummary,
LoreStructuralContext,
NarrativeEntityContext,
NpcSummary,
PageContext,
PageSummary,
SceneSummary,
SessionContext,
)
def _build(**kw) -> str:
return ChatUseCase(None).build_system_prompt(**kw)
def _empty_lore() -> LoreStructuralContext:
return LoreStructuralContext(lore_name="L", lore_description=None, folders={}, tags=[])
def test_base_prompt_without_context_is_non_empty():
assert len(_build()) > 0
def test_lore_block_renders_pages_with_values_tags_and_links():
lore = LoreStructuralContext(
lore_name="Eldoria", lore_description="un monde sombre",
folders={"PNJ": [PageSummary(
title="Aragorn", template_name="Personnage",
values={"apparence": "grand et noble"}, tags=["héros"],
related_page_titles=["Gondor"])]},
tags=["dark-fantasy"])
p = _build(lore_context=lore)
assert "Eldoria" in p
assert "un monde sombre" in p
assert "Aragorn" in p
assert "apparence" in p and "grand et noble" in p
assert "héros" in p
assert "Gondor" in p
def test_empty_lore_signals_vide():
assert "Lore vide" in _build(lore_context=_empty_lore())
def test_page_context_block_lists_fields_and_empty_marker():
page = PageContext(title="Aragorn", template_name="Personnage",
template_fields=["apparence", "histoire"],
values={"apparence": "grand"})
p = _build(page_context=page)
assert "PAGE EN COURS" in p
assert "Aragorn" in p
assert "apparence" in p
assert "(vide)" in p # 'histoire' sans valeur
def test_campaign_block_with_arc_and_empty_pj_npc_and_no_lore_note():
camp = CampaignStructuralContext(
campaign_name="La Malédiction", campaign_description="horreur gothique",
arcs=[ArcSummary(name="Acte I", description="intro",
chapters=[ChapterSummary(name="Ch1", description="",
scenes=[SceneSummary(name="Sc1", description="")])])],
characters=[], npcs=[])
p = _build(campaign_context=camp)
assert "CAMPAGNE COURANTE" in p
assert "La Malédiction" in p
assert "Acte I" in p
assert "aucune fiche" in p # pas de PJ
assert "aucun univers" in p # pas de lore lié
def test_campaign_with_characters_npcs_and_lore_present_note():
camp = CampaignStructuralContext(
campaign_name="C", campaign_description=None, arcs=[],
characters=[CharacterSummary(name="Tav", snippet="roublarde")],
npcs=[NpcSummary(name="Strahd", snippet="vampire de Barovia")])
p = _build(campaign_context=camp, lore_context=_empty_lore())
assert "Tav" in p and "roublarde" in p
assert "Strahd" in p and "vampire de Barovia" in p
assert "liée à l'univers" in p
def test_game_system_narrative_and_session_sections_injected():
gs = GameSystemContext(system_name="Nimble", system_description=None,
sections={"Combat": "règles de combat"})
narr = NarrativeEntityContext(entity_type="scene", title="L'auberge du Portail",
fields={"ambiance": "tendue"})
sess = SessionContext(
session_name="Séance 3", active=True, started_at=None,
entries=[JournalEntrySummary(type="EVENT", content="Le pont s'effondre", occurred_at=None)],
previous_events=[])
p = _build(lore_context=_empty_lore(), game_system_context=gs,
narrative_entity=narr, session_context=sess)
assert "Nimble" in p
assert "L'auberge du Portail" in p
assert "Séance 3" in p
assert "Le pont s'effondre" in p
async def test_stream_passes_built_prompt_to_llm():
class FakeChatLLM:
def __init__(self) -> None:
self.system_prompt = None
async def stream_chat(self, messages, *, system_prompt=None, temperature=None):
self.system_prompt = system_prompt
yield "tok"
llm = FakeChatLLM()
out = [t async for t in ChatUseCase(llm).stream(
[ChatMessage(role="user", content="salut")],
lore_context=LoreStructuralContext("Eldoria", None, {}, []))]
assert out == ["tok"]
assert "Eldoria" in llm.system_prompt

View File

@@ -0,0 +1,72 @@
"""Tests du découpage de texte (app.application.chunking).
Vérifie le découpage par paragraphes vers une cible de tokens, le découpage des
paragraphes géants, le recouvrement (overlap), et le split_in_half du repli
anti-troncature. tiktoken (cl100k_base) est déterministe → assertions stables.
"""
from __future__ import annotations
from app.application.chunking import chunk_text, split_in_half
def test_empty_text_returns_no_chunk():
assert chunk_text("") == []
assert chunk_text(" \n\n ") == []
def test_short_text_stays_single_chunk():
chunks = chunk_text("Paragraphe un.\n\nParagraphe deux.", target_tokens=1000)
assert len(chunks) == 1
assert "Paragraphe un." in chunks[0]
assert "Paragraphe deux." in chunks[0]
def test_splits_into_several_chunks_when_exceeding_target():
paras = [f"Paragraphe numero {i} avec un peu de contenu." for i in range(20)]
full = "\n\n".join(paras)
chunks = chunk_text(full, target_tokens=20)
assert len(chunks) > 1
# Aucun paragraphe perdu : tous présents quelque part.
joined = "\n\n".join(chunks)
for p in paras:
assert p in joined
def test_oversized_single_paragraph_is_split():
# Un seul paragraphe (aucun "\n\n") plus gros que la cible → plusieurs sous-blocs.
huge = "mot " * 500
chunks = chunk_text(huge, target_tokens=50)
assert len(chunks) > 1
def test_overlap_repeats_content_without_losing_paragraphs():
paras = [f"Bloc {i} de texte distinct." for i in range(12)]
full = "\n\n".join(paras)
chunks = chunk_text(full, target_tokens=20, overlap_tokens=10)
assert len(chunks) > 1
joined = "\n\n".join(chunks)
for p in paras:
assert p in joined
# --- split_in_half -------------------------------------------------------------
def test_split_in_half_too_short_returns_empty():
assert split_in_half("court") == ("", "")
def test_split_in_half_splits_on_newline_near_middle():
text = "A" * 300 + "\n" + "B" * 300
left, right = split_in_half(text)
assert left and right
assert left.startswith("A")
assert right.startswith("B")
def test_split_in_half_halves_cover_all_content():
text = "\n".join(f"ligne {i} " + "x" * 20 for i in range(40))
left, right = split_in_half(text)
assert left and right
# Le découpage ne perd rien : la concaténation contient début et fin.
assert "ligne 0" in left
assert "ligne 39" in right

View File

@@ -0,0 +1,102 @@
"""Tests des adapters d'embeddings (Mistral cloud + Ollama local)."""
from __future__ import annotations
import json
import httpx
import pytest
import respx
from app.application.embeddings import EmbeddingError
from app.core.config import Settings
from app.infrastructure.mistral_embedding_adapter import MistralEmbeddingProvider
from app.infrastructure.ollama_embedding_adapter import OllamaEmbeddingProvider
_MISTRAL = "https://api.mistral.ai/v1/embeddings"
_OLLAMA = "http://ollama:11434/api/embed"
def _settings(**kw) -> Settings:
base = dict(_env_file=None, llm_timeout_seconds=30, ollama_base_url="http://ollama:11434")
base.update(kw)
return Settings(**base)
# --- Mistral -------------------------------------------------------------------
def test_mistral_missing_key_raises_at_construction():
with pytest.raises(EmbeddingError):
MistralEmbeddingProvider(_settings(mistral_api_key=""))
async def test_mistral_empty_texts_short_circuits():
svc = MistralEmbeddingProvider(_settings(mistral_api_key="k"))
assert await svc.embed([]) == []
@respx.mock
async def test_mistral_returns_vectors():
respx.post(_MISTRAL).mock(return_value=httpx.Response(200, json={
"data": [{"embedding": [0.1, 0.2]}, {"embedding": [0.3, 0.4]}]
}))
svc = MistralEmbeddingProvider(_settings(mistral_api_key="k", mistral_embedding_model="mistral-embed"))
vectors = await svc.embed(["texte un", "texte deux"])
assert vectors == [[0.1, 0.2], [0.3, 0.4]]
@respx.mock
async def test_mistral_http_error_raises():
respx.post(_MISTRAL).mock(return_value=httpx.Response(429, text="rate limit"))
svc = MistralEmbeddingProvider(_settings(mistral_api_key="k"))
with pytest.raises(EmbeddingError) as exc:
await svc.embed(["x"])
assert "429" in str(exc.value)
@respx.mock
async def test_mistral_size_mismatch_raises():
respx.post(_MISTRAL).mock(return_value=httpx.Response(200, json={"data": [{"embedding": [0.1]}]}))
svc = MistralEmbeddingProvider(_settings(mistral_api_key="k"))
with pytest.raises(EmbeddingError):
await svc.embed(["a", "b"]) # 2 demandés, 1 reçu
# --- Ollama --------------------------------------------------------------------
async def test_ollama_empty_texts_short_circuits():
svc = OllamaEmbeddingProvider(_settings(ollama_embedding_model="nomic-embed-text"))
assert await svc.embed([]) == []
@respx.mock
async def test_ollama_returns_vectors():
respx.post(_OLLAMA).mock(return_value=httpx.Response(200, json={"embeddings": [[0.1], [0.2]]}))
svc = OllamaEmbeddingProvider(_settings(ollama_embedding_model="mxbai-embed-large"))
assert await svc.embed(["a", "b"]) == [[0.1], [0.2]]
@respx.mock
async def test_ollama_applies_nomic_task_prefix():
route = respx.post(_OLLAMA).mock(return_value=httpx.Response(200, json={"embeddings": [[0.0]]}))
svc = OllamaEmbeddingProvider(_settings(ollama_embedding_model="nomic-embed-text"))
await svc.embed(["question ?"], kind="query")
sent = json.loads(route.calls.last.request.content)["input"]
assert sent == ["search_query: question ?"]
@respx.mock
async def test_ollama_no_prefix_for_non_nomic_model():
route = respx.post(_OLLAMA).mock(return_value=httpx.Response(200, json={"embeddings": [[0.0]]}))
svc = OllamaEmbeddingProvider(_settings(ollama_embedding_model="mxbai-embed-large"))
await svc.embed(["doc"], kind="document")
sent = json.loads(route.calls.last.request.content)["input"]
assert sent == ["doc"]
@respx.mock
async def test_ollama_http_error_mentions_pull_hint():
respx.post(_OLLAMA).mock(return_value=httpx.Response(404, text="model not found"))
svc = OllamaEmbeddingProvider(_settings(ollama_embedding_model="nomic-embed-text"))
with pytest.raises(EmbeddingError) as exc:
await svc.embed(["x"])
assert "ollama pull" in str(exc.value)

View File

@@ -0,0 +1,65 @@
"""Tests du use case de génération de page (app.application.generate_page)."""
from __future__ import annotations
import pytest
from app.application.generate_page import GeneratePageUseCase
from app.domain.models import PageGenerationContext
from app.domain.ports import LLMProviderError
_CTX = PageGenerationContext(
lore_name="Eldoria",
folder_name="PNJ",
template_name="Personnage",
template_fields=["apparence", "histoire"],
page_title="Aragorn",
lore_description="un monde sombre",
)
def test_build_prompt_includes_context_and_fields():
p = GeneratePageUseCase._build_prompt(_CTX, "fr")
assert "Eldoria" in p
assert "Aragorn" in p
assert '"apparence"' in p
assert "un monde sombre" in p
def test_build_prompt_omits_lore_description_when_absent():
ctx = PageGenerationContext("L", "F", "T", ["a"], "Titre", None)
assert "Description de l'univers" not in GeneratePageUseCase._build_prompt(ctx)
def test_parse_values_keeps_only_expected_fields():
out = GeneratePageUseCase._parse_values(
'{"apparence":"grand","histoire":"longue","extra":"ignoré"}',
["apparence", "histoire"])
assert out == {"apparence": "grand", "histoire": "longue"}
def test_parse_values_missing_field_becomes_empty_string():
out = GeneratePageUseCase._parse_values('{"apparence":"grand"}', ["apparence", "histoire"])
assert out == {"apparence": "grand", "histoire": ""}
def test_parse_values_casts_to_str_and_strips():
out = GeneratePageUseCase._parse_values('{"n": 42, "s": " x "}', ["n", "s"])
assert out == {"n": "42", "s": "x"}
def test_parse_values_bad_json_raises():
with pytest.raises(LLMProviderError):
GeneratePageUseCase._parse_values("pas du json", ["a"])
def test_parse_values_non_object_raises():
with pytest.raises(LLMProviderError):
GeneratePageUseCase._parse_values("[1, 2]", ["a"])
async def test_execute_returns_filtered_result():
class FakeLLM:
async def generate(self, prompt, *, output_format=None, temperature=None):
return '{"apparence":"grand","histoire":"épique","parasite":"x"}'
result = await GeneratePageUseCase(FakeLLM()).execute(_CTX)
assert result.values == {"apparence": "grand", "histoire": "épique"}

View File

@@ -0,0 +1,95 @@
"""Tests des parseurs robustes des use cases d'import (méthodes statiques).
_parse_payload (campagne), _parse_sections / _parse_anchors (règles) : transforment
la réponse brute du LLM en structure exploitable + un drapeau « tronqué » qui
déclenche le re-découpage.
"""
from __future__ import annotations
from app.application.import_campaign import ImportCampaignUseCase
from app.application.import_rules import ImportRulesUseCase
_parse_payload = ImportCampaignUseCase._parse_payload
_parse_sections = ImportRulesUseCase._parse_sections
_parse_anchors = ImportRulesUseCase._parse_anchors
# --- campagne : _parse_payload -------------------------------------------------
def test_parse_payload_valid():
payload, truncated = _parse_payload('{"arcs":[{"name":"A"}],"npcs":[{"name":"N"}]}', index=0)
assert truncated is False
assert payload == {"arcs": [{"name": "A"}], "npcs": [{"name": "N"}]}
def test_parse_payload_truncated_flags_recut():
payload, truncated = _parse_payload('{"arcs":[{"name":"A"', index=0)
assert truncated is True
assert payload == {"arcs": [], "npcs": []}
def test_parse_payload_prose_is_empty_not_truncated():
payload, truncated = _parse_payload('juste de la prose sans json', index=0)
assert truncated is False
assert payload == {"arcs": [], "npcs": []}
def test_parse_payload_recovers_truncated_array():
raw = '{"arcs":[{"name":"A"},{"name":"B"},{"name":'
payload, truncated = _parse_payload(raw, index=0)
assert truncated is True
assert payload == {"arcs": [{"name": "A"}, {"name": "B"}], "npcs": []}
def test_parse_payload_coerces_non_list_fields():
payload, _ = _parse_payload('{"arcs":"oops","npcs":null}', index=0)
assert payload == {"arcs": [], "npcs": []}
# --- règles : _parse_sections --------------------------------------------------
def test_parse_sections_valid_and_normalized():
sections, truncated = _parse_sections('{"sections":{"Combat":"texte"}}', index=0)
assert truncated is False
assert sections == {"Combat": "texte"}
def test_parse_sections_truncated():
sections, truncated = _parse_sections('{"Combat":"texte non termin', index=0)
assert truncated is True
assert sections == {}
def test_parse_sections_prose_is_empty():
sections, truncated = _parse_sections('pas de json ici', index=0)
assert truncated is False
assert sections == {}
# --- règles (mode segmentation) : _parse_anchors -------------------------------
def test_parse_anchors_locates_and_splits_text():
text = "Préambule.\nLE COMBAT commence ici, brutal.\nLA MAGIE ensuite, subtile."
raw = ('{"sections":[{"titre":"Combat","debut":"LE COMBAT commence"},'
'{"titre":"Magie","debut":"LA MAGIE ensuite"}]}')
sections, truncated = _parse_anchors(raw, text, index=0)
assert truncated is False
assert "Combat" in sections and "Magie" in sections
# La 1re section absorbe le préambule (avant la 1re ancre).
assert "Préambule." in sections["Combat"]
assert "LE COMBAT commence ici, brutal." in sections["Combat"]
assert "LA MAGIE ensuite, subtile." in sections["Magie"]
def test_parse_anchors_unparseable_returns_empty():
sections, truncated = _parse_anchors("pas du json", "texte", index=0)
assert sections == {}
assert truncated is False
def test_parse_anchors_anchor_not_found_is_dropped():
text = "Seulement ce paragraphe existe."
raw = '{"sections":[{"titre":"Fantôme","debut":"ancre absente du texte"}]}'
sections, _ = _parse_anchors(raw, text, index=0)
# Aucune ancre localisée → aucune section.
assert sections == {}

View File

@@ -0,0 +1,30 @@
"""Tests du canal de statut d'import (app.application.import_status)."""
from __future__ import annotations
import asyncio
from app.application import import_status
def test_notify_is_noop_without_queue():
# Hors import (aucune queue installée) : ne lève pas, ne fait rien.
import_status.notify_status("personne n'écoute") # ne doit pas lever
def test_notify_publishes_when_queue_installed():
queue: asyncio.Queue = asyncio.Queue()
token = import_status.set_status_queue(queue)
try:
import_status.notify_status("morceau re-découpé")
assert queue.get_nowait() == "morceau re-découpé"
finally:
import_status.reset_status_queue(token)
def test_reset_restores_noop():
queue: asyncio.Queue = asyncio.Queue()
token = import_status.set_status_queue(queue)
import_status.reset_status_queue(token)
# Après reset : plus de queue active → no-op, la queue reste vide.
import_status.notify_status("ignoré")
assert queue.empty()

View File

@@ -0,0 +1,157 @@
"""Tests des use cases d'import via FAKES (ports LLM + extracteur PDF).
Exerce la chaîne map-reduce complète (extraction → chunking → MAP → REDUCE →
streaming d'événements) SANS réseau ni vrai PDF. `chunk_text` est monkeypatché
pour un découpage déterministe (le chunking est testé à part). `asyncio.sleep`
est neutralisé pour que les backoffs de retry n'imposent aucune attente.
"""
from __future__ import annotations
import pytest
from app.application.import_campaign import ImportCampaignUseCase
from app.application.import_rules import ImportRulesUseCase
from app.domain.models import ExtractedDocument, ExtractedPage
from app.domain.ports import LLMProviderError
# --- fakes ---------------------------------------------------------------------
class FakeExtractor:
def __init__(self, doc: ExtractedDocument) -> None:
self._doc = doc
def extract(self, pdf_bytes: bytes) -> ExtractedDocument:
return self._doc
class ScriptedLLM:
"""Rejoue une réponse par appel (la dernière est répétée si on dépasse)."""
def __init__(self, responses: list) -> None:
self._responses = list(responses)
self.calls = 0
async def generate(self, prompt: str, *, output_format=None, temperature=None) -> str:
r = self._responses[min(self.calls, len(self._responses) - 1)]
self.calls += 1
if isinstance(r, Exception):
raise r
return r
class ContentLLM:
"""Répond selon le CONTENU du prompt (chunk) : (sous-chaîne → réponse/exception)."""
def __init__(self, rules: list) -> None:
self._rules = rules
async def generate(self, prompt: str, *, output_format=None, temperature=None) -> str:
for sub, r in self._rules:
if sub in prompt:
if isinstance(r, Exception):
raise r
return r
raise AssertionError(f"aucune règle ContentLLM ne matche : {prompt[:60]!r}")
def _doc(text: str = "Texte du PDF.", *, ocr: bool = False) -> ExtractedDocument:
return ExtractedDocument(pages=[ExtractedPage(index=0, text=text, used_ocr=ocr)])
@pytest.fixture
def no_sleep(monkeypatch):
async def _noop(_d):
return None
monkeypatch.setattr("asyncio.sleep", _noop)
@pytest.fixture
def one_chunk(monkeypatch):
monkeypatch.setattr("app.application.import_rules.chunk_text", lambda *a, **k: ["chunk"])
monkeypatch.setattr("app.application.import_campaign.chunk_text", lambda *a, **k: ["chunk"])
# --- import de règles ----------------------------------------------------------
async def test_rules_execute_returns_merged_sections(one_chunk):
llm = ScriptedLLM(['{"Combat":"## Combat\\nrègles de combat"}'])
uc = ImportRulesUseCase(llm, FakeExtractor(_doc(ocr=True)))
result = await uc.execute(b"pdf")
assert result.sections == {"Combat": "## Combat\nrègles de combat"}
assert result.page_count == 1
assert result.ocr_page_count == 1
async def test_rules_stream_emits_extracting_start_progress_done(one_chunk):
llm = ScriptedLLM(['{"Magie":"sorts"}'])
uc = ImportRulesUseCase(llm, FakeExtractor(_doc()))
events = [e async for e in uc.stream(b"pdf")]
types = [e["type"] for e in events]
assert types[0] == "extracting"
assert types[1] == "start"
assert "progress" in types
done = events[-1]
assert done["type"] == "done"
assert done["sections"] == {"Magie": "sorts"}
async def test_rules_stream_skips_failed_chunk_but_continues(monkeypatch, no_sleep):
monkeypatch.setattr("app.application.import_rules.chunk_text",
lambda *a, **k: ["AAA premier", "BBB second"])
llm = ContentLLM([
("AAA premier", LLMProviderError("HTTP 503 saturé")),
("BBB second", '{"Magie":"sorts"}'),
])
uc = ImportRulesUseCase(llm, FakeExtractor(_doc()))
events = [e async for e in uc.stream(b"pdf")]
types = [e["type"] for e in events]
assert "chunk_failed" in types
done = events[-1]
assert done["type"] == "done"
assert done["sections"] == {"Magie": "sorts"}
assert done["skipped"] == 1
async def test_rules_stream_all_chunks_fail_emits_error(one_chunk, no_sleep):
llm = ScriptedLLM([LLMProviderError("HTTP 500 panne")])
uc = ImportRulesUseCase(llm, FakeExtractor(_doc()))
events = [e async for e in uc.stream(b"pdf")]
assert events[-1]["type"] == "error"
assert "échoué" in events[-1]["message"]
# --- import de campagne --------------------------------------------------------
_TREE = ('{"arcs":[{"name":"Acte I","description":"intro",'
'"chapters":[{"name":"Ch1","scenes":[{"name":"Sc1"}]}]}],'
'"npcs":[{"name":"Gandalf","description":"magicien"}]}')
async def test_campaign_execute_builds_tree_and_npcs(one_chunk):
uc = ImportCampaignUseCase(ScriptedLLM([_TREE]), FakeExtractor(_doc()))
result = await uc.execute(b"pdf")
assert result.counts() == (1, 1, 1)
assert result.arcs[0].name == "Acte I"
assert result.arcs[0].chapters[0].scenes[0].name == "Sc1"
assert [n.name for n in result.npcs] == ["Gandalf"]
async def test_campaign_stream_emits_done_with_serialized_tree(one_chunk):
uc = ImportCampaignUseCase(ScriptedLLM([_TREE]), FakeExtractor(_doc()))
events = [e async for e in uc.stream(b"pdf")]
types = [e["type"] for e in events]
assert types[0] == "extracting"
assert types[1] == "start"
assert "progress" in types
done = events[-1]
assert done["type"] == "done"
assert done["arcs"][0]["name"] == "Acte I"
assert done["arcs"][0]["chapters"][0]["scenes"][0]["name"] == "Sc1"
assert done["npcs"] == [{"name": "Gandalf", "description": "magicien"}]
async def test_campaign_stream_all_fail_emits_error(one_chunk, no_sleep):
uc = ImportCampaignUseCase(ScriptedLLM([LLMProviderError("502")]), FakeExtractor(_doc()))
events = [e async for e in uc.stream(b"pdf")]
assert events[-1]["type"] == "error"

View File

@@ -0,0 +1,39 @@
"""Tests de la normalisation de langue (app.core.language)."""
from __future__ import annotations
import pytest
from app.core import language
@pytest.mark.parametrize("raw, expected", [
("fr", "fr"),
("en", "en"),
("EN", "en"),
("en-US", "en"),
("fr-FR,fr;q=0.9,en;q=0.8", "fr"),
("en-GB,en;q=0.9", "en"),
("de", "fr"), # non supporté → défaut
("", "fr"),
(None, "fr"),
(" EN-gb ", "en"), # casse + espaces tolérés
])
def test_normalize(raw, expected):
assert language.normalize(raw) == expected
def test_language_name_known_and_fallback():
assert language.language_name("fr") == "français"
assert language.language_name("en") == "anglais"
# Code inconnu → nom de la langue par défaut.
assert language.language_name("xx") == "français"
def test_instruction_mentions_target_language():
assert "anglais" in language.instruction("en")
assert "français" in language.instruction("fr")
def test_get_user_language_uses_normalize():
assert language.get_user_language("en-US") == "en"
assert language.get_user_language(None) == "fr"

View File

@@ -0,0 +1,130 @@
"""Tests de la lecture robuste de JSON depuis une réponse LLM (app.application.llm_json).
Couvre l'extraction du premier objet équilibré (en ignorant les accolades dans
les chaînes), la réparation d'un JSON tronqué, la détection « ça ressemble à du
JSON coupé », et le strip des blocs de raisonnement <think>…</think>.
"""
from __future__ import annotations
from app.application.llm_json import (
extract_json_object,
load_json_object,
looks_like_truncated_json,
repair_truncated_json,
)
# --- extract_json_object -------------------------------------------------------
def test_extract_simple_object():
assert extract_json_object('{"a": 1}') == '{"a": 1}'
def test_extract_ignores_surrounding_prose_and_fences():
raw = 'Voici le JSON :\n```json\n{"a": 1}\n```\nMerci.'
assert extract_json_object(raw) == '{"a": 1}'
def test_extract_stops_at_first_balanced_object():
assert extract_json_object('{"a": 1} et puis {"b": 2}') == '{"a": 1}'
def test_extract_keeps_nested_object_whole():
assert extract_json_object('{"a": {"b": 1}}') == '{"a": {"b": 1}}'
def test_extract_ignores_braces_inside_strings():
raw = '{"a": "}{ pas du json "}'
assert extract_json_object(raw) == raw
def test_extract_handles_escaped_quote_in_string():
raw = '{"a": "x\\"y"}'
assert extract_json_object(raw) == raw
def test_extract_returns_none_when_unclosed():
assert extract_json_object('{"a": 1') is None
def test_extract_returns_none_without_brace():
assert extract_json_object('aucune accolade ici') is None
def test_extract_returns_none_on_empty():
assert extract_json_object('') is None
# --- load_json_object ----------------------------------------------------------
def test_load_valid_object_not_recovered():
obj, recovered = load_json_object('{"x": 42}')
assert obj == {"x": 42}
assert recovered is False
def test_load_tolerates_raw_control_chars_in_strings():
# Retour à la ligne BRUT dans une chaîne : invalide en strict, accepté ici.
obj, recovered = load_json_object('{"a": "ligne1\nligne2"}')
assert obj == {"a": "ligne1\nligne2"}
assert recovered is False
def test_load_strips_reasoning_block_before_parsing():
raw = '<think>je réfléchis { ] [ }</think>{"ok": true}'
obj, recovered = load_json_object(raw)
assert obj == {"ok": True}
assert recovered is False
def test_load_repairs_truncated_array_and_flags_recovered():
raw = '{"items": [{"a": 1}, {"b": 2}, {"c":'
obj, recovered = load_json_object(raw)
assert obj == {"items": [{"a": 1}, {"b": 2}]}
assert recovered is True
def test_load_returns_none_on_garbage():
obj, recovered = load_json_object('juste de la prose sans json')
assert obj is None
assert recovered is False
# --- looks_like_truncated_json -------------------------------------------------
def test_truncated_detection_no_brace_is_false():
assert looks_like_truncated_json('rien') is False
def test_truncated_detection_short_object_start_unbalanced_is_true():
# Démarre par '{' et déséquilibré → coupé net, même très court.
assert looks_like_truncated_json('{"') is True
def test_truncated_detection_balanced_object_is_false():
assert looks_like_truncated_json('{"a": 1}') is False
def test_truncated_detection_short_prose_with_braces_is_false():
assert looks_like_truncated_json('texte { incomplet') is False
def test_truncated_detection_long_prose_unbalanced_is_true():
raw = 'prose ' * 30 + '{ structure ouverte mais jamais refermée'
assert len(raw) >= 100
assert looks_like_truncated_json(raw) is True
# --- repair_truncated_json -----------------------------------------------------
def test_repair_closes_open_containers_after_last_complete_element():
repaired = repair_truncated_json('{"items": [{"a": 1}, {"b": 2}, {"c":')
assert repaired == '{"items": [{"a": 1}, {"b": 2}]}'
def test_repair_returns_none_when_nothing_complete():
assert repair_truncated_json('{"a": "jamais fermé') is None
def test_repair_returns_none_without_brace():
assert repair_truncated_json('pas de json') is None

View File

@@ -0,0 +1,112 @@
"""Tests du retry des appels LLM one-shot (app.application.llm_retry).
`asyncio.sleep` est neutralisé (et enregistré) pour que les backoffs n'imposent
aucune attente réelle tout en vérifiant les durées choisies.
"""
from __future__ import annotations
import pytest
from app.application import llm_retry
from app.application.llm_retry import (
_is_daily_quota,
_is_rate_limit,
_suggested_retry_after,
generate_with_retry,
)
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
class FakeLLM:
"""LLM factice : rejoue une liste de comportements (exception ou texte)."""
def __init__(self, behaviors: list) -> None:
self._behaviors = list(behaviors)
self.calls = 0
async def generate(self, prompt: str, *, output_format=None, temperature=None) -> str:
b = self._behaviors[self.calls]
self.calls += 1
if isinstance(b, Exception):
raise b
return b
@pytest.fixture
def slept(monkeypatch):
"""Neutralise asyncio.sleep et enregistre les durées demandées."""
recorded: list[float] = []
async def fake_sleep(d):
recorded.append(d)
monkeypatch.setattr("asyncio.sleep", fake_sleep)
return recorded
# --- helpers de classification -------------------------------------------------
def test_is_rate_limit():
assert _is_rate_limit(LLMProviderError("HTTP 429 Too Many Requests"))
assert _is_rate_limit(LLMProviderError("rate limit reached"))
assert not _is_rate_limit(LLMProviderError("HTTP 500"))
def test_is_daily_quota():
assert _is_daily_quota(LLMProviderError("free-models-per-day limit"))
assert _is_daily_quota(LLMProviderError("quota per day exceeded"))
assert not _is_daily_quota(LLMProviderError("429 per-minute"))
def test_suggested_retry_after():
assert _suggested_retry_after(LLMProviderError('{"retry_after_seconds": 8}')) == 8.0
assert _suggested_retry_after(LLMProviderError('Retry-After: 12')) == 12.0
assert _suggested_retry_after(LLMProviderError("pas de hint")) is None
# --- generate_with_retry -------------------------------------------------------
async def test_returns_on_first_success(slept):
llm = FakeLLM(["réponse"])
assert await generate_with_retry(llm, "p") == "réponse"
assert llm.calls == 1
assert slept == []
async def test_retries_transient_error_then_succeeds(slept):
llm = FakeLLM([LLMProviderError("HTTP 503"), "ok"])
assert await generate_with_retry(llm, "p") == "ok"
assert llm.calls == 2
assert slept == [3.0] # _BASE_DELAY_SECONDS
async def test_timeout_raises_immediately_without_retry(slept):
llm = FakeLLM([LLMGenerationTimeout("trop lent")])
with pytest.raises(LLMGenerationTimeout):
await generate_with_retry(llm, "p")
assert llm.calls == 1
assert slept == []
async def test_daily_quota_aborts_immediately(slept):
llm = FakeLLM([LLMProviderError("free-models-per-day exceeded")])
with pytest.raises(LLMProviderError):
await generate_with_retry(llm, "p")
assert llm.calls == 1
assert slept == []
async def test_exhausts_attempts_then_raises_last(slept):
llm = FakeLLM([LLMProviderError("503 a"), LLMProviderError("503 b"), LLMProviderError("503 c")])
with pytest.raises(LLMProviderError, match="503 c"):
await generate_with_retry(llm, "p")
assert llm.calls == 3
# 2 attentes entre 3 tentatives (backoff exponentiel 3s puis 6s).
assert slept == [3.0, 6.0]
async def test_rate_limit_respects_suggested_retry_after(slept):
llm = FakeLLM([LLMProviderError('429 {"retry_after_seconds": 8}'), "ok"])
assert await generate_with_retry(llm, "p") == "ok"
# min(8 + 2, 60) = 10
assert slept == [10.0]

View File

@@ -0,0 +1,52 @@
"""Tests de la logique portée par les modèles de domaine (app.domain.models)."""
from __future__ import annotations
from app.domain.models import (
ArcProposal,
CampaignImportResult,
ChapterProposal,
ExtractedDocument,
ExtractedPage,
RulesImportResult,
SceneProposal,
)
def test_extracted_document_properties():
doc = ExtractedDocument(pages=[
ExtractedPage(index=0, text="page un", used_ocr=False),
ExtractedPage(index=1, text="page deux", used_ocr=True),
ExtractedPage(index=2, text=" ", used_ocr=False), # vide → exclue de full_text
])
assert doc.page_count == 3
assert doc.ocr_page_count == 1
assert doc.full_text == "page un\n\npage deux"
def test_rules_import_result_to_markdown():
result = RulesImportResult(
sections={"Combat": "règles de combat", "Magie": "règles de magie"},
page_count=10, ocr_page_count=0,
)
md = result.to_markdown()
assert "## Combat\n\nrègles de combat" in md
assert "## Magie\n\nrègles de magie" in md
assert md.endswith("\n")
def test_campaign_import_result_counts():
arcs = [
ArcProposal("A1", "", chapters=[
ChapterProposal("C1", "", scenes=[SceneProposal("S1", ""), SceneProposal("S2", "")]),
ChapterProposal("C2", "", scenes=[SceneProposal("S3", "")]),
]),
ArcProposal("A2", "", chapters=[]),
]
result = CampaignImportResult(arcs=arcs, page_count=1, ocr_page_count=0)
assert result.counts() == (2, 2, 3)
def test_arc_proposal_defaults():
arc = ArcProposal("Acte", "synopsis")
assert arc.arc_type == "LINEAR"
assert arc.chapters == []

View File

@@ -0,0 +1,95 @@
"""Tests de caractérisation de l'adapter Ollama (protocole propre : /api/generate
one-shot + /api/chat NDJSON streamé)."""
from __future__ import annotations
import json
import httpx
import pytest
import respx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
from app.infrastructure.ollama_adapter import OllamaLLMProvider
_GEN = "http://ollama:11434/api/generate"
_CHAT = "http://ollama:11434/api/chat"
def _svc() -> OllamaLLMProvider:
s = Settings(_env_file=None, ollama_base_url="http://ollama:11434",
llm_model="gemma", llm_timeout_seconds=30, llm_num_ctx=8192)
return OllamaLLMProvider(s)
@respx.mock
async def test_generate_returns_response_field():
respx.post(_GEN).mock(return_value=httpx.Response(200, json={"response": "texte", "done_reason": "stop"}))
assert await _svc().generate("prompt") == "texte"
@respx.mock
async def test_generate_payload_always_sends_num_ctx_and_omits_temperature():
route = respx.post(_GEN).mock(return_value=httpx.Response(200, json={"response": "x"}))
await _svc().generate("p")
body = json.loads(route.calls.last.request.content)
assert body["model"] == "gemma"
assert body["stream"] is False
assert body["options"] == {"num_ctx": 8192}
assert "format" not in body
@respx.mock
async def test_generate_payload_includes_temperature_and_format_when_given():
route = respx.post(_GEN).mock(return_value=httpx.Response(200, json={"response": "x"}))
await _svc().generate("p", output_format="json", temperature=0.1)
body = json.loads(route.calls.last.request.content)
assert body["options"]["temperature"] == 0.1
assert body["format"] == "json"
@respx.mock
async def test_generate_http_error_surfaces_ollama_message():
respx.post(_GEN).mock(return_value=httpx.Response(404, json={"error": "model 'x' not found"}))
with pytest.raises(LLMProviderError) as exc:
await _svc().generate("p")
assert "not found" in str(exc.value)
assert "404" in str(exc.value)
@respx.mock
async def test_generate_read_timeout_is_generation_timeout():
respx.post(_GEN).mock(side_effect=httpx.ReadTimeout("trop lent"))
with pytest.raises(LLMGenerationTimeout):
await _svc().generate("p")
@respx.mock
async def test_generate_connect_timeout_is_provider_error():
respx.post(_GEN).mock(side_effect=httpx.ConnectTimeout("injoignable"))
with pytest.raises(LLMProviderError) as exc:
await _svc().generate("p")
assert not isinstance(exc.value, LLMGenerationTimeout)
@respx.mock
async def test_stream_chat_yields_tokens_until_done():
body = (
'{"message":{"content":"Bon"},"done":false}\n'
'{"message":{"content":"jour"},"done":false}\n'
'{"done":true}\n'
)
respx.post(_CHAT).mock(return_value=httpx.Response(200, text=body))
tokens = [t async for t in _svc().stream_chat([ChatMessage(role="user", content="hi")])]
assert tokens == ["Bon", "jour"]
@respx.mock
async def test_stream_chat_prepends_system_prompt():
route = respx.post(_CHAT).mock(return_value=httpx.Response(200, text='{"done":true}\n'))
_ = [t async for t in _svc().stream_chat(
[ChatMessage(role="user", content="Q")], system_prompt="SYS")]
body = json.loads(route.calls.last.request.content)
assert body["messages"][0] == {"role": "system", "content": "SYS"}
assert body["messages"][-1] == {"role": "user", "content": "Q"}

View File

@@ -0,0 +1,110 @@
"""Tests de l'adapter 1min.ai (API propriétaire : prompt unique aplati, SSE
`event: content`/`data:{content}`)."""
from __future__ import annotations
import httpx
import pytest
import respx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
_URL = "https://api.1min.ai/api/chat-with-ai?isStreaming=true"
def _svc() -> OneMinAiLLMProvider:
s = Settings(_env_file=None, onemin_api_key="k", onemin_model="gpt-4o-mini",
llm_timeout_seconds=30)
return OneMinAiLLMProvider(s)
def _sse(*blocks: str) -> str:
return "".join(blocks)
# --- streaming -----------------------------------------------------------------
@respx.mock
async def test_generate_collects_content_chunks():
body = _sse(
"event: content\ndata: {\"content\": \"Bon\"}\n\n",
"event: content\ndata: {\"content\": \"jour\"}\n\n",
"event: done\ndata: {}\n\n",
)
respx.post(_URL).mock(return_value=httpx.Response(200, text=body))
assert await _svc().generate("salut") == "Bonjour"
@respx.mock
async def test_generate_sends_api_key_header_and_prompt_payload():
route = respx.post(_URL).mock(return_value=httpx.Response(
200, text="event: done\ndata: {}\n\n"))
await _svc().generate("ma question")
req = route.calls.last.request
assert req.headers["API-KEY"] == "k"
import json
body = json.loads(req.content)
assert body["model"] == "gpt-4o-mini"
assert body["promptObject"]["prompt"] == "ma question"
@respx.mock
async def test_error_event_raises_provider_error():
body = "event: error\ndata: {\"message\": \"quota dépassé\"}\n\n"
respx.post(_URL).mock(return_value=httpx.Response(200, text=body))
with pytest.raises(LLMProviderError) as exc:
await _svc().generate("p")
assert "quota dépassé" in str(exc.value)
@respx.mock
async def test_http_error_is_translated():
respx.post(_URL).mock(return_value=httpx.Response(502, text="bad gateway"))
with pytest.raises(LLMProviderError) as exc:
await _svc().generate("p")
assert "1min.ai" in str(exc.value)
@respx.mock
async def test_stream_chat_flattens_and_streams():
route = respx.post(_URL).mock(return_value=httpx.Response(
200, text="event: content\ndata: {\"content\": \"R\"}\n\nevent: done\ndata: {}\n\n"))
tokens = [t async for t in _svc().stream_chat(
[ChatMessage(role="user", content="Q")], system_prompt="SYS")]
assert tokens == ["R"]
import json
prompt = json.loads(route.calls.last.request.content)["promptObject"]["prompt"]
assert "[SYSTEM]" in prompt and "SYS" in prompt
assert "[USER]" in prompt and "Q" in prompt
# --- helpers purs --------------------------------------------------------------
def test_flatten_messages_structure():
out = OneMinAiLLMProvider._flatten_messages(
[ChatMessage(role="user", content="Q1"), ChatMessage(role="assistant", content="R1")],
"instructions système",
)
assert "[SYSTEM]\ninstructions système" in out
assert "[USER]\nQ1" in out
assert "[ASSISTANT]\nR1" in out
assert out.rstrip().endswith("[ASSISTANT]")
def test_extract_content_chunk_json_and_fallback():
assert OneMinAiLLMProvider._extract_content_chunk('{"content": "x"}') == "x"
assert OneMinAiLLMProvider._extract_content_chunk('{"token": "y"}') == "y"
# Non-JSON : filet de sécurité, on renvoie le brut.
assert OneMinAiLLMProvider._extract_content_chunk("texte brut") == "texte brut"
def test_extract_result_reads_nested_result_object():
payload = {"aiRecord": {"aiRecordDetail": {"resultObject": ["partie 1", "partie 2"]}}}
assert OneMinAiLLMProvider._extract_result(payload) == "partie 1partie 2"
def test_extract_result_raises_on_unexpected_schema():
with pytest.raises(LLMProviderError):
OneMinAiLLMProvider._extract_result({"unexpected": True})

View File

@@ -0,0 +1,204 @@
"""Tests de caractérisation des adapters LLM « OpenAI-compatible »
(OpenRouter, Gemini, Mistral).
But : VERROUILLER le comportement observable AVANT d'extraire une classe de base
commune (les trois adapters partageaient ~80 % de code). On couvre via respx
(mock du transport httpx) : collecte du stream, payload envoyé, en-têtes, parsing
SSE, et traduction des erreurs HTTP — sans aucun appel réseau réel.
Ces tests doivent rester verts à l'identique après le refactor.
"""
from __future__ import annotations
import json
import httpx
import pytest
import respx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
from app.infrastructure.gemini_adapter import GeminiLLMProvider
from app.infrastructure.mistral_adapter import MistralLLMProvider
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
def _settings(**kw) -> Settings:
return Settings(_env_file=None, llm_timeout_seconds=30, **kw)
def _sse(*contents: str) -> str:
"""Construit un corps SSE OpenAI : une trame `data: {choices:[{delta:{content}}]}`
par fragment, terminé par `data: [DONE]`."""
lines: list[str] = []
for c in contents:
lines.append("data: " + json.dumps({"choices": [{"delta": {"content": c}}]}))
lines.append("")
lines += ["data: [DONE]", ""]
return "\n".join(lines)
# (id, classe, url, kwargs settings (clé+modèle), supporte response_format=json_object)
CASES = [
pytest.param(
OpenRouterLLMProvider,
"https://openrouter.ai/api/v1/chat/completions",
dict(openrouter_api_key="k", openrouter_model="m"),
False,
"OpenRouter",
id="openrouter",
),
pytest.param(
GeminiLLMProvider,
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
dict(gemini_api_key="k", gemini_model="m"),
True,
"Gemini",
id="gemini",
),
pytest.param(
MistralLLMProvider,
"https://api.mistral.ai/v1/chat/completions",
dict(mistral_api_key="k", mistral_model="m"),
True,
"Mistral",
id="mistral",
),
]
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_generate_collects_full_stream(cls, url, skw, supports_json, label):
respx.post(url).mock(return_value=httpx.Response(200, text=_sse("Bonjour", " le", " monde")))
svc = cls(_settings(**skw))
assert await svc.generate("salut") == "Bonjour le monde"
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_stream_chat_yields_tokens(cls, url, skw, supports_json, label):
respx.post(url).mock(return_value=httpx.Response(200, text=_sse("A", "B", "C")))
svc = cls(_settings(**skw))
tokens = [t async for t in svc.stream_chat([ChatMessage(role="user", content="hi")])]
assert tokens == ["A", "B", "C"]
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_payload_system_prompt_and_temperature(cls, url, skw, supports_json, label):
route = respx.post(url).mock(return_value=httpx.Response(200, text=_sse("x")))
svc = cls(_settings(**skw))
_ = [t async for t in svc.stream_chat(
[ChatMessage(role="user", content="Q")],
system_prompt="SYS",
temperature=0.5,
)]
body = json.loads(route.calls.last.request.content)
assert body["model"] == "m"
assert body["stream"] is True
assert body["messages"][0] == {"role": "system", "content": "SYS"}
assert body["messages"][-1] == {"role": "user", "content": "Q"}
assert body["temperature"] == 0.5
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_payload_omits_temperature_when_none(cls, url, skw, supports_json, label):
route = respx.post(url).mock(return_value=httpx.Response(200, text=_sse("x")))
svc = cls(_settings(**skw))
await svc.generate("p")
body = json.loads(route.calls.last.request.content)
assert "temperature" not in body
# Sans system_prompt, generate envoie un unique message user.
assert body["messages"] == [{"role": "user", "content": "p"}]
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_response_format_json_only_when_supported(cls, url, skw, supports_json, label):
route = respx.post(url).mock(return_value=httpx.Response(200, text=_sse("{}")))
svc = cls(_settings(**skw))
await svc.generate("p", output_format="json")
body = json.loads(route.calls.last.request.content)
if supports_json:
assert body["response_format"] == {"type": "json_object"}
else:
assert "response_format" not in body
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_authorization_header_bearer(cls, url, skw, supports_json, label):
route = respx.post(url).mock(return_value=httpx.Response(200, text=_sse("x")))
svc = cls(_settings(**skw))
await svc.generate("p")
assert route.calls.last.request.headers["Authorization"] == "Bearer k"
@respx.mock
async def test_openrouter_attribution_headers():
route = respx.post("https://openrouter.ai/api/v1/chat/completions").mock(
return_value=httpx.Response(200, text=_sse("x")))
svc = OpenRouterLLMProvider(_settings(openrouter_api_key="k", openrouter_model="m"))
await svc.generate("p")
headers = route.calls.last.request.headers
assert headers["HTTP-Referer"] == "https://loremind.app"
assert headers["X-Title"] == "LoreMind"
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_http_error_translated_to_provider_error(cls, url, skw, supports_json, label):
respx.post(url).mock(return_value=httpx.Response(429, text="quota exceeded"))
svc = cls(_settings(**skw))
with pytest.raises(LLMProviderError) as exc:
await svc.generate("p")
msg = str(exc.value)
assert label in msg
assert "429" in msg
assert "quota exceeded" in msg
@respx.mock
async def test_gemini_rejected_key_gives_actionable_message():
respx.post(
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
).mock(return_value=httpx.Response(403, text="API key not valid"))
svc = GeminiLLMProvider(_settings(gemini_api_key="k", gemini_model="m"))
with pytest.raises(LLMProviderError) as exc:
await svc.generate("p")
assert "refusée par Google" in str(exc.value)
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_sse_skips_keepalive_and_malformed_lines(cls, url, skw, supports_json, label):
body = "\n".join([
": OPENROUTER PROCESSING", # commentaire keep-alive
"",
"data: not-json", # JSON invalide -> ignoré
"",
"data: " + json.dumps({"choices": []}), # pas de choix -> ignoré
"",
"data: " + json.dumps({"choices": [{"delta": {}}]}), # delta sans content -> ignoré
"",
"data: " + json.dumps({"choices": [{"delta": {"content": "OK"}}]}),
"",
"data: [DONE]",
"",
])
respx.post(url).mock(return_value=httpx.Response(200, text=body))
svc = cls(_settings(**skw))
assert await svc.generate("p") == "OK"
@pytest.mark.parametrize("cls, skw", [
pytest.param(OpenRouterLLMProvider, dict(openrouter_api_key=""), id="openrouter"),
pytest.param(GeminiLLMProvider, dict(gemini_api_key=""), id="gemini"),
pytest.param(MistralLLMProvider, dict(mistral_api_key=""), id="mistral"),
])
def test_missing_api_key_raises_at_construction(cls, skw):
with pytest.raises(LLMProviderError):
cls(_settings(**skw))

View File

@@ -0,0 +1,54 @@
"""Tests de la réécriture de question autonome (app.application.query_rewrite)."""
from __future__ import annotations
from app.application.query_rewrite import standalone_question
from app.domain.models import ChatMessage
class FakeLLM:
def __init__(self, response: str | None = None, exc: Exception | None = None) -> None:
self.response = response
self.exc = exc
self.called = False
async def generate(self, prompt, *, temperature=None, output_format=None) -> str:
self.called = True
if self.exc:
raise self.exc
return self.response
async def test_single_turn_returns_last_user_without_calling_llm():
llm = FakeLLM()
q = await standalone_question(llm, [ChatMessage(role="user", content="Qui est Strahd ?")])
assert q == "Qui est Strahd ?"
assert llm.called is False
async def test_multi_turn_uses_llm_rewrite_and_strips_quotes():
llm = FakeLLM(response='"Quelles sont les faiblesses de Strahd ?"')
msgs = [
ChatMessage(role="user", content="Qui est Strahd ?"),
ChatMessage(role="assistant", content="Un vampire."),
ChatMessage(role="user", content="Et ses faiblesses ?"),
]
assert await standalone_question(llm, msgs) == "Quelles sont les faiblesses de Strahd ?"
assert llm.called is True
async def test_llm_failure_falls_back_to_last_user():
llm = FakeLLM(exc=RuntimeError("LLM HS"))
msgs = [ChatMessage(role="user", content="A"), ChatMessage(role="user", content="B")]
assert await standalone_question(llm, msgs) == "B"
async def test_suspiciously_long_rewrite_falls_back():
llm = FakeLLM(response="x" * 500)
msgs = [ChatMessage(role="user", content="A"), ChatMessage(role="user", content="B")]
assert await standalone_question(llm, msgs) == "B"
async def test_empty_messages_returns_empty_string():
llm = FakeLLM()
assert await standalone_question(llm, []) == ""
assert llm.called is False

View File

@@ -0,0 +1,60 @@
"""Tests du reranking LLM des passages RAG (app.application.rerank)."""
from __future__ import annotations
from app.application.rerank import pool_size, rerank
class FakeLLM:
def __init__(self, response: str | None = None, exc: Exception | None = None) -> None:
self.response = response
self.exc = exc
async def generate(self, prompt, *, temperature=None, output_format=None) -> str:
if self.exc:
raise self.exc
return self.response
def test_pool_size():
assert pool_size(8) == 24 # min(max(24, 8), 24)
assert pool_size(4) == 12 # 4 * 3
assert pool_size(10) == 24 # plafonné à POOL_MAX
assert pool_size(1) == 3
async def test_rerank_skips_when_pool_not_larger_than_top_k():
passages = [{"text": "a"}, {"text": "b"}]
# len <= top_k → renvoyé tel quel, sans appel LLM.
assert await rerank(FakeLLM(exc=AssertionError("ne doit pas être appelé")),
"q", passages, top_k=3) == passages
async def test_rerank_reorders_by_llm_scores():
passages = [{"text": "a"}, {"text": "b"}, {"text": "c"}]
out = await rerank(FakeLLM(response='{"scores":[1, 9, 5]}'), "q", passages, top_k=2)
assert [p["text"] for p in out] == ["b", "c"]
async def test_rerank_stable_on_score_ties():
passages = [{"text": "a"}, {"text": "b"}, {"text": "c"}]
# Notes égales → ordre cosinus d'origine préservé.
out = await rerank(FakeLLM(response='{"scores":[5, 5, 5]}'), "q", passages, top_k=2)
assert [p["text"] for p in out] == ["a", "b"]
async def test_rerank_llm_failure_falls_back_to_cosine_order():
passages = [{"text": "a"}, {"text": "b"}, {"text": "c"}]
out = await rerank(FakeLLM(exc=RuntimeError("LLM HS")), "q", passages, top_k=2)
assert [p["text"] for p in out] == ["a", "b"]
async def test_rerank_wrong_score_count_falls_back():
passages = [{"text": "a"}, {"text": "b"}, {"text": "c"}]
out = await rerank(FakeLLM(response='{"scores":[1, 2]}'), "q", passages, top_k=2)
assert [p["text"] for p in out] == ["a", "b"]
async def test_rerank_non_numeric_scores_fall_back():
passages = [{"text": "a"}, {"text": "b"}, {"text": "c"}]
out = await rerank(FakeLLM(response='{"scores":["x","y","z"]}'), "q", passages, top_k=2)
assert [p["text"] for p in out] == ["a", "b"]

View File

@@ -0,0 +1,107 @@
"""Tests des helpers de l'import de règles (app.application.import_rules) :
_SectionMerger, _normalize_sections, _coerce_markdown, _find_anchor, _combine_sections.
"""
from __future__ import annotations
from app.application.import_rules import (
_SectionMerger,
_coerce_markdown,
_combine_sections,
_find_anchor,
_normalize_sections,
)
# --- _SectionMerger ------------------------------------------------------------
def test_section_merger_case_insensitive_and_joins():
m = _SectionMerger()
touched = m.add({"Combat": "règle A", "combat": "règle B"})
assert touched == ["Combat"] # clé canonique = 1re vue
res = m.result()
assert list(res.keys()) == ["Combat"]
assert res["Combat"] == "règle A\n\nrègle B"
def test_section_merger_skips_empty_title_or_content():
m = _SectionMerger()
touched = m.add({"": "x", "Titre": " ", "Vrai": "contenu"})
assert touched == ["Vrai"]
assert m.result() == {"Vrai": "contenu"}
def test_section_merger_accumulates_across_chunks():
m = _SectionMerger()
m.add({"Combat": "p1"})
m.add({"Combat": "p2", "Magie": "sorts"})
res = m.result()
assert res["Combat"] == "p1\n\np2"
assert res["Magie"] == "sorts"
# --- _normalize_sections -------------------------------------------------------
def test_normalize_unwraps_known_envelope():
assert _normalize_sections({"sections": {"Combat": "x"}}) == {"Combat": "x"}
assert _normalize_sections({"règles": {"A": "y"}}) == {"A": "y"}
def test_normalize_title_content_schema():
assert _normalize_sections({"title": "Combat", "content": "texte"}) == {"Combat": "texte"}
def test_normalize_strips_meta_keys():
assert _normalize_sections({"Combat": "x", "thought": "bla", "notes": "y"}) == {"Combat": "x"}
def test_normalize_passthrough_plain_sections():
assert _normalize_sections({"A": "1", "B": "2"}) == {"A": "1", "B": "2"}
# --- _coerce_markdown ----------------------------------------------------------
def test_coerce_markdown_string_passthrough():
assert _coerce_markdown("texte") == "texte"
def test_coerce_markdown_none_is_empty():
assert _coerce_markdown(None) == ""
def test_coerce_markdown_list_joined():
assert _coerce_markdown(["a", "b"]) == "a\n\nb"
def test_coerce_markdown_dict_flattened():
out = _coerce_markdown({"Sous-titre": "contenu"})
assert "Sous-titre" in out
assert "contenu" in out
# --- _find_anchor --------------------------------------------------------------
def test_find_anchor_exact():
text = "Chapitre 1. Le héros entre."
assert _find_anchor(text, "Le héros entre", 0) == text.index("Le héros entre")
def test_find_anchor_whitespace_flexible():
text = "Le héros\nentre dans la taverne."
# Espaces multiples / saut de ligne dans le texte source, anchor normalisé.
assert _find_anchor(text, "Le héros entre dans la taverne", 0) is not None
def test_find_anchor_case_insensitive():
assert _find_anchor("LE DONJON s'ouvre", "le donjon", 0) is not None
def test_find_anchor_not_found():
assert _find_anchor("texte quelconque", "introuvable xyz", 0) is None
# --- _combine_sections ---------------------------------------------------------
def test_combine_sections_case_insensitive_concat():
out = _combine_sections({"Combat": "p1"}, {"combat": "p2", "Magie": "sorts"})
assert out["Combat"] == "p1\n\np2"
assert out["Magie"] == "sorts"

View File

@@ -0,0 +1,57 @@
"""Tests des overrides runtime persistés (app.core.settings_store).
Le chemin du fichier est redirigé vers un tmp_path pour isoler chaque test.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from app.core import settings_store
@pytest.fixture(autouse=True)
def isolated_store(tmp_path, monkeypatch):
monkeypatch.setattr(settings_store, "_OVERRIDES_PATH", tmp_path / "settings.json")
return tmp_path / "settings.json"
def test_load_missing_file_returns_empty():
assert settings_store.load_overrides() == {}
def test_save_filters_to_allowlist_and_persists(isolated_store):
result = settings_store.save_overrides({
"llm_model": "gemma3:12b",
"internal_shared_secret": "HACK", # hors allow-list → ignoré
"champ_inconnu": "x", # hors allow-list → ignoré
})
assert result == {"llm_model": "gemma3:12b"}
on_disk = json.loads(Path(isolated_store).read_text(encoding="utf-8"))
assert on_disk == {"llm_model": "gemma3:12b"}
def test_save_merges_with_existing():
settings_store.save_overrides({"llm_model": "a"})
merged = settings_store.save_overrides({"llm_provider": "ollama"})
assert merged == {"llm_model": "a", "llm_provider": "ollama"}
def test_load_ignores_non_allowlisted_keys_on_disk(isolated_store):
Path(isolated_store).write_text(
json.dumps({"llm_model": "ok", "internal_shared_secret": "leak"}),
encoding="utf-8",
)
assert settings_store.load_overrides() == {"llm_model": "ok"}
def test_load_corrupted_file_returns_empty(isolated_store):
Path(isolated_store).write_text("{ pas du json", encoding="utf-8")
assert settings_store.load_overrides() == {}
def test_load_non_dict_json_returns_empty(isolated_store):
Path(isolated_store).write_text("[1, 2, 3]", encoding="utf-8")
assert settings_store.load_overrides() == {}

View File

@@ -0,0 +1,48 @@
"""Tests des heartbeats SSE (app.application.streaming.with_heartbeat)."""
from __future__ import annotations
import asyncio
import pytest
from app.application.streaming import with_heartbeat
async def _collect(agen) -> list[tuple[str, object]]:
return [ev async for ev in agen]
async def test_fast_coro_emits_only_result():
async def quick() -> int:
return 42
events = await _collect(with_heartbeat(quick(), interval=0.05))
assert events == [("result", 42)]
async def test_slow_coro_emits_heartbeats_then_result():
async def slow() -> str:
await asyncio.sleep(0.06)
return "fini"
events = await _collect(with_heartbeat(slow(), interval=0.02))
assert ("heartbeat", None) in events
assert events[-1] == ("result", "fini")
async def test_relays_status_messages_from_queue():
queue: asyncio.Queue = asyncio.Queue()
async def work() -> str:
await asyncio.sleep(0.05)
return "ok"
queue.put_nowait("fournisseur saturé, nouvel essai")
events = await _collect(with_heartbeat(work(), interval=0.02, status_queue=queue))
assert ("status", "fournisseur saturé, nouvel essai") in events
assert events[-1] == ("result", "ok")
async def test_propagates_coro_exception():
async def boom() -> None:
raise ValueError("échec interne")
with pytest.raises(ValueError, match="échec interne"):
await _collect(with_heartbeat(boom(), interval=0.05))

View File

@@ -0,0 +1,149 @@
"""Tests du _TreeMerger de l'import de campagne (app.application.import_campaign).
Cœur du REDUCE : fusion par nom (insensible à la casse) des sous-arbres
arc→chapitre→scène→pièce produits morceau par morceau, + accumulation des PNJ.
"""
from __future__ import annotations
from app.application.import_campaign import _TreeMerger
def test_single_chunk_builds_full_tree():
m = _TreeMerger()
m.add([{
"name": "Acte I", "description": "intro",
"chapters": [{
"name": "Ch1", "description": "d",
"scenes": [{
"name": "Sc1", "description": "s",
"player_narration": "PN", "gm_notes": "GM",
"rooms": [{"name": "R1", "description": "rd", "enemies": "gob", "loot": "or"}],
}],
}],
}])
arcs = m.result()
assert len(arcs) == 1
arc = arcs[0]
assert arc.name == "Acte I"
assert arc.arc_type == "LINEAR"
sc = arc.chapters[0].scenes[0]
assert sc.player_narration == "PN"
assert sc.gm_notes == "GM"
room = sc.rooms[0]
assert (room.name, room.enemies, room.loot) == ("R1", "gob", "or")
def test_case_insensitive_arc_and_chapter_merge():
m = _TreeMerger()
m.add([{"name": "Acte I", "chapters": [{"name": "Ch1", "scenes": []}]}])
m.add([{"name": "acte i", "chapters": [{"name": "ch1", "scenes": []},
{"name": "Ch2", "scenes": []}]}])
arcs = m.result()
assert len(arcs) == 1
assert {c.name for c in arcs[0].chapters} == {"Ch1", "Ch2"}
def test_description_first_non_empty_wins():
m = _TreeMerger()
m.add([{"name": "A", "description": "", "chapters": []}])
m.add([{"name": "A", "description": "vraie", "chapters": []}])
m.add([{"name": "A", "description": "autre", "chapters": []}])
assert m.result()[0].description == "vraie"
def test_hub_type_wins_if_any_chunk_signals_it():
m = _TreeMerger()
m.add([{"name": "A", "type": "LINEAR", "chapters": []}])
m.add([{"name": "A", "type": "HUB", "chapters": []}])
assert m.result()[0].arc_type == "HUB"
def _scene(narr=None, gm=None):
s = {"name": "S"}
if narr is not None:
s["player_narration"] = narr
if gm is not None:
s["gm_notes"] = gm
return {"name": "A", "chapters": [{"name": "C", "scenes": [s]}]}
def test_scene_narration_concatenated_across_chunks():
m = _TreeMerger()
m.add([_scene(narr="début")])
m.add([_scene(narr="suite")])
sc = m.result()[0].chapters[0].scenes[0]
assert sc.player_narration == "début\n\nsuite"
def test_scene_field_dedups_exact_overlap():
m = _TreeMerger()
m.add([_scene(gm="texte identique")])
m.add([_scene(gm="texte identique")])
assert m.result()[0].chapters[0].scenes[0].gm_notes == "texte identique"
def test_scene_field_takes_superset_version():
m = _TreeMerger()
m.add([_scene(gm="court")])
m.add([_scene(gm="court et bien plus long")])
assert m.result()[0].chapters[0].scenes[0].gm_notes == "court et bien plus long"
def test_npcs_longest_description_wins():
m = _TreeMerger()
m.add_npcs([{"name": "Thorin", "description": "court"}])
m.add_npcs([{"name": "thorin", "description": "une description bien plus complète"}])
npcs = m.npcs()
assert len(npcs) == 1
assert npcs[0].name == "Thorin"
assert npcs[0].description == "une description bien plus complète"
def test_counts():
m = _TreeMerger()
m.add([{"name": "A", "chapters": [
{"name": "C1", "scenes": [{"name": "S1"}, {"name": "S2"}]},
{"name": "C2", "scenes": []},
]}])
assert m.counts() == (1, 2, 2)
def test_blank_names_are_skipped():
m = _TreeMerger()
m.add([{"name": "", "chapters": []},
{"name": " ", "chapters": []},
{"name": "OK", "chapters": [{"name": "", "scenes": []}]}])
arcs = m.result()
assert len(arcs) == 1
assert arcs[0].name == "OK"
assert arcs[0].chapters == []
def test_merge_chapters_consolidation():
m = _TreeMerger()
m.add([{"name": "A", "chapters": [
{"name": "Intro", "scenes": [{"name": "S1"}]},
{"name": "Introduction", "scenes": [{"name": "S2"}]},
]}])
assert m.merge_chapters("Intro", ["Introduction"]) is True
chapters = m.result()[0].chapters
assert len(chapters) == 1
assert {s.name for s in chapters[0].scenes} == {"S1", "S2"}
def test_merge_chapters_unknown_target_returns_false():
m = _TreeMerger()
m.add([{"name": "A", "chapters": [{"name": "Intro", "scenes": []}]}])
assert m.merge_chapters("Inexistant", ["Intro"]) is False
def test_merge_scenes_consolidation():
m = _TreeMerger()
m.add([{"name": "A", "chapters": [{"name": "C", "scenes": [
{"name": "Combat", "gm_notes": "x"},
{"name": "Le combat", "gm_notes": "y"},
]}]}])
assert m.merge_scenes("C", "Combat", ["Le combat"]) is True
scenes = m.result()[0].chapters[0].scenes
assert len(scenes) == 1
assert scenes[0].name == "Combat"

View File

@@ -0,0 +1,119 @@
"""Tests du stockage vectoriel fichier + recherche hybride (app.infrastructure.vector_store).
Le répertoire de stockage est redirigé vers un tmp_path et le cache mémoire est
vidé avant chaque test pour une isolation totale.
"""
from __future__ import annotations
import pytest
from app.infrastructure import vector_store
@pytest.fixture(autouse=True)
def isolated_store(tmp_path, monkeypatch):
monkeypatch.setattr(vector_store, "_STORE_DIR", tmp_path)
vector_store._CACHE.clear()
yield
vector_store._CACHE.clear()
# --- cosinus -------------------------------------------------------------------
def test_cosine_identical_is_one():
assert vector_store._cosine([1.0, 0.0], [2.0, 0.0]) == pytest.approx(1.0)
def test_cosine_orthogonal_is_zero():
assert vector_store._cosine([1.0, 0.0], [0.0, 1.0]) == 0.0
def test_cosine_mismatched_or_zero_is_zero():
assert vector_store._cosine([1.0], [1.0, 2.0]) == 0.0
assert vector_store._cosine([0.0, 0.0], [1.0, 1.0]) == 0.0
assert vector_store.cosine_similarity([], [1.0]) == 0.0 # alias public
# --- mots significatifs --------------------------------------------------------
def test_significant_words_filters_stopwords_and_short():
words = vector_store._significant_words("Le dragon DORT dans la caverne avec les gobelins")
assert "dragon" in words
assert "caverne" in words
assert "gobelins" in words
assert "les" not in words and "avec" not in words and "la" not in words
# --- save / exists / delete ----------------------------------------------------
def test_save_then_exists_and_delete():
vector_store.save("src1", ["chunk a"], [[1.0, 0.0]])
assert vector_store.exists("src1") is True
vector_store.delete("src1")
assert vector_store.exists("src1") is False
def test_save_rejects_mismatched_lengths():
with pytest.raises(ValueError):
vector_store.save("s", ["a", "b"], [[1.0]])
with pytest.raises(ValueError):
vector_store.save("s", ["a"], [[1.0]], pages=[1, 2])
def test_all_chunks_returns_text_and_page():
vector_store.save("s", ["t1", "t2"], [[1.0], [2.0]], pages=[3, 7])
chunks = vector_store.all_chunks("s")
assert chunks == [{"text": "t1", "page": 3}, {"text": "t2", "page": 7}]
# --- recherche -----------------------------------------------------------------
def test_search_ranks_by_cosine():
vector_store.save("s", ["proche", "loin"], [[1.0, 0.0], [0.0, 1.0]])
results = vector_store.search(["s"], [1.0, 0.0], top_k=2)
assert [r["text"] for r in results] == ["proche", "loin"]
assert results[0]["score"] > results[1]["score"]
def test_search_respects_top_k():
vector_store.save("s", ["a", "b", "c"], [[1.0], [0.9], [0.8]])
assert len(vector_store.search(["s"], [1.0], top_k=2)) == 2
def test_search_min_score_filters_out_weak_matches():
vector_store.save("s", ["proche", "orthogonal"], [[1.0, 0.0], [0.0, 1.0]])
results = vector_store.search(["s"], [1.0, 0.0], top_k=5, min_score=0.5)
assert [r["text"] for r in results] == ["proche"]
def test_search_lexical_bonus_promotes_exact_term_match():
# Deux extraits de cosinus IDENTIQUE : le bonus lexical départage celui qui
# contient le mot exact de la question.
vector_store.save(
"s",
["Strahd règne sur Barovia", "un texte neutre sans rapport"],
[[1.0, 0.0], [1.0, 0.0]],
)
results = vector_store.search(["s"], [1.0, 0.0], top_k=2, query_text="Strahd")
assert results[0]["text"] == "Strahd règne sur Barovia"
assert results[0]["score"] > results[1]["score"]
def test_search_includes_source_id_and_page():
vector_store.save("livre", ["extrait"], [[1.0]], pages=[42])
[res] = vector_store.search(["livre"], [1.0], top_k=1)
assert res["source_id"] == "livre"
assert res["page"] == 42
# --- résumés (analyse approfondie) ---------------------------------------------
def test_summaries_roundtrip_keyed_by_batch_tokens():
vector_store.save_summaries("s", 1000, [{"summary": "résumé", "vector": [1.0]}])
assert vector_store.load_summaries("s", 1000) == [{"summary": "résumé", "vector": [1.0]}]
# Taille de lot différente → invalidé (le découpage ne correspondrait plus).
assert vector_store.load_summaries("s", 2000) is None
def test_load_summaries_absent_returns_none():
assert vector_store.load_summaries("inconnu", 1000) is None