diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..8563a3b --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,62 @@ +name: Tests unitaires + +# Gate de qualité : lance les 3 suites unitaires (Java / Python / Angular) à +# chaque push sur main et sur chaque PR. Une suite rouge fait échouer la CI +# (et, via la branch protection Gitea, peut bloquer le merge). +# Le build/push des images (release.yml) dépend AUSSI de ces tests via `needs`. +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + core: + name: Core (Java · mvn test + JaCoCo) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + # `mvn test` exécute aussi jacoco:report + jacoco:check (plancher 60%). + - name: mvn test + working-directory: core + run: mvn -B test + + brain: + name: Brain (Python · pytest + couverture) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: brain/requirements-dev.txt + - name: Install deps (test) + working-directory: brain + run: pip install -r requirements-dev.txt + - name: pytest (+ plancher couverture 50%) + working-directory: brain + run: pytest --cov=app --cov-report=term-missing --cov-fail-under=50 + + web: + name: Web (Angular · vitest + couverture) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: web/package-lock.json + - name: npm ci + working-directory: web + run: npm ci --no-audit --no-fund + # vitest run --coverage applique les seuils définis dans vitest.config.ts. + - name: vitest (+ seuils couverture) + working-directory: web + run: npm run test:unit:coverage diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 1d1a04c..07ebc2e 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -12,7 +12,51 @@ env: GHCR_NAMESPACE: igmlcreation jobs: + # GATE : aucune image n'est build/push tant que les 3 suites unitaires + # (Java / Python / Angular) ne passent pas. Un tag posé sur un commit aux + # tests rouges ne publiera donc PAS d'images. + tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + - name: Core — mvn test (+ JaCoCo check) + working-directory: core + run: mvn -B test + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: brain/requirements-dev.txt + - name: Brain — pytest (+ couverture) + working-directory: brain + run: | + pip install -r requirements-dev.txt + pytest --cov=app --cov-report=term-missing --cov-fail-under=50 + + - name: Set up Node 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: web/package-lock.json + - name: Web — vitest (+ couverture) + working-directory: web + run: | + npm ci --no-audit --no-fund + npm run test:unit:coverage + build: + needs: tests runs-on: ubuntu-latest strategy: fail-fast: false @@ -93,6 +137,7 @@ jobs: # donc uniquement sur les releases stables — pas la peine de re-publier # une variante beta du switcher, c'est une infrastructure neutre. build-switcher: + needs: tests runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.gitignore b/.gitignore index a55a13f..57d6fd4 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,8 @@ brain/data/notebooks/5.json # ============================================================================ docusaurus/loremind-patreon/ installers/desktop/README.md + +# Rapports de couverture de tests +web/coverage/ +brain/htmlcov/ +brain/.coverage diff --git a/brain/.coveragerc b/brain/.coveragerc new file mode 100644 index 0000000..f731c46 --- /dev/null +++ b/brain/.coveragerc @@ -0,0 +1,15 @@ +# Configuration de couverture (coverage.py / pytest-cov). +# Rapport HTML (équivalent JaCoCo) : pytest --cov=app --cov-report=html → htmlcov/ +# Plancher anti-régression appliqué en CI : --cov-fail-under=50 +[run] +source = app +branch = false + +[report] +show_missing = true +skip_covered = false +# Lignes jamais comptées comme « à couvrir ». +exclude_lines = + pragma: no cover + if __name__ == .__main__.: + raise NotImplementedError diff --git a/brain/.gitignore b/brain/.gitignore index a98520d..4cf7d3e 100644 --- a/brain/.gitignore +++ b/brain/.gitignore @@ -2,3 +2,8 @@ __pycache__/ *.pyc .env + +# Couverture de tests (pytest-cov) +htmlcov/ +.coverage +.pytest_cache/ diff --git a/brain/app/infrastructure/base_openai_adapter.py b/brain/app/infrastructure/base_openai_adapter.py new file mode 100644 index 0000000..305c63c --- /dev/null +++ b/brain/app/infrastructure/base_openai_adapter.py @@ -0,0 +1,226 @@ +"""Socle commun aux adapters LLM « OpenAI-compatible » (OpenRouter, Gemini, +Mistral) — ils exposent tous `POST {base}/chat/completions` en SSE avec le même +schéma de payload et de flux. + +Cette classe de base porte la mécanique partagée (construction du payload, appel +HTTP streamé, parsing SSE, garde-fous de timeout au temps écoulé, traduction des +erreurs). Chaque adapter concret ne fournit plus que ses spécificités : +URL, en-têtes, support du mode JSON natif, messages d'erreur, lecture de la config. + +`generate` one-shot passe lui aussi par le streaming (puis recollage) pour éviter +les coupures de passerelle sur les longues générations (cf. Cloudflare 524). +""" +from __future__ import annotations + +import asyncio +import json +import logging +from typing import AsyncIterator + +import httpx + +from app.domain.models import ChatMessage +from app.domain.ports import LLMGenerationTimeout, LLMProviderError + +logger = logging.getLogger(__name__) + +# Délai max pour le PREMIER token de contenu. Un modèle « en file d'attente » +# n'envoie que des keep-alive (aucun contenu) → on échoue vite et clairement au +# lieu de pendre. Le timeout réseau d'httpx ne suffit pas : des keep-alive font +# « arriver des octets » et empêchent son read-timeout de se déclencher. +_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0 + + +class BaseOpenAICompatibleAdapter: + """Base des adapters clients d'une API OpenAI-compatible (chat/completions SSE). + + Satisfait par duck typing les ports LLMProvider et LLMChatProvider. Les + sous-classes définissent : ``_provider_label``, ``_api_url``, + ``_supports_json_object`` (mode JSON natif), et surchargent au besoin + ``_headers`` / ``_error_for_status`` / les messages de timeout. + """ + + # Surchargés par les sous-classes. + _provider_label: str = "LLM" + _api_url: str = "" + _supports_json_object: bool = False + + def __init__(self, api_key: str, model: str, timeout: int) -> None: + self._api_key = api_key + self._model = model + self._timeout = timeout + + # --- Spécificités surchargeables ---------------------------------------- + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + } + + def _first_token_timeout_message(self) -> str: + return ( + f"Erreur {self._provider_label} : aucun contenu produit en " + f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle est probablement en " + "file d'attente / saturé. Réessayez plus tard ou choisissez un autre modèle." + ) + + def _generation_timeout_message(self) -> str: + return ( + f"Erreur {self._provider_label} : génération non terminée en {self._timeout}s. " + "Réduisez la taille des morceaux d'import, augmentez le timeout, ou changez de modèle." + ) + + def _error_for_status(self, status_code: int, detail: str) -> LLMProviderError: + """Erreur de domaine pour une réponse HTTP >= 400 (détail déjà lu).""" + return LLMProviderError( + f"Erreur {self._provider_label} (HTTP {status_code})" + + (f" : {detail[:500]}" if detail else "") + ) + + # --- API publique (ports) ----------------------------------------------- + + async def generate( + self, + prompt: str, + *, + output_format: str | None = None, + temperature: float | None = None, + ) -> str: + """One-shot via streaming (puis recollage), avec garde-fous au temps écoulé.""" + return await self._collect_with_timeouts( + [ChatMessage(role="user", content=prompt)], temperature, output_format + ) + + async def stream_chat( + self, + messages: list[ChatMessage], + *, + system_prompt: str | None = None, + temperature: float | None = None, + ) -> AsyncIterator[str]: + async for token in self._stream(messages, system_prompt, temperature): + yield token + + # --- Mécanique partagée ------------------------------------------------- + + async def _collect_with_timeouts( + self, + messages: list[ChatMessage], + temperature: float | None, + output_format: str | None, + ) -> str: + """Collecte le stream avec DEUX garde-fous au temps écoulé : + - 1er token borné (`_FIRST_TOKEN_TIMEOUT_SECONDS`) : détecte un modèle bloqué + en file d'attente (que des keep-alive, aucun contenu) → échec rapide ; + - ceiling global (`self._timeout`) : génération qui ne se termine jamais. + """ + async def _collect() -> str: + chunks: list[str] = [] + agen = self._stream(messages, None, temperature, output_format) + try: + while True: + # Borne SEULEMENT l'attente du 1er token ; ensuite on laisse + # générer (le ceiling global couvre le reste). + first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None + try: + token = await asyncio.wait_for(agen.__anext__(), timeout=first) + except StopAsyncIteration: + break + except asyncio.TimeoutError: + raise LLMProviderError(self._first_token_timeout_message()) + chunks.append(token) + finally: + await agen.aclose() + return "".join(chunks) + + try: + return await asyncio.wait_for(_collect(), timeout=self._timeout) + except asyncio.TimeoutError as exc: + raise LLMGenerationTimeout(self._generation_timeout_message()) from exc + + def _build_body( + self, + messages: list[ChatMessage], + system_prompt: str | None, + temperature: float | None, + output_format: str | None, + ) -> dict[str, object]: + payload_messages: list[dict[str, str]] = [] + if system_prompt: + payload_messages.append({"role": "system", "content": system_prompt}) + for m in messages: + payload_messages.append({"role": m.role, "content": m.content}) + + body: dict[str, object] = { + "model": self._model, + "messages": payload_messages, + "stream": True, + } + if temperature is not None: + body["temperature"] = temperature + # Mode JSON natif : supprime les fences ```json et le JSON invalide (retours + # à la ligne bruts), principale cause de morceaux d'import ignorés. Un SCHÉMA + # (dict) est traduit en json_object — suffisant, les grands modèles cloud + # respectent la structure demandée par le prompt. Désactivé pour les + # providers/modèles gratuits qui ne le supportent pas (réponse vide). + if self._supports_json_object and output_format is not None: + body["response_format"] = {"type": "json_object"} + return body + + async def _stream( + self, + messages: list[ChatMessage], + system_prompt: str | None, + temperature: float | None, + output_format: str | None = None, + ) -> AsyncIterator[str]: + body = self._build_body(messages, system_prompt, temperature, output_format) + async with httpx.AsyncClient(timeout=self._timeout) as client: + try: + async with client.stream( + "POST", self._api_url, headers=self._headers(), json=body + ) as response: + if response.status_code >= 400: + # En streaming le corps n'est pas lu automatiquement : on le + # lit pour exposer le détail du provider (le 429 précise le + # type de quota, le 401 la clé invalide…), sinon on n'a que + # le code HTTP nu et le diagnostic est impossible. + detail = (await response.aread()).decode("utf-8", "replace").strip() + raise self._error_for_status(response.status_code, detail) + async for token in self._parse_sse(response): + yield token + except httpx.HTTPError as exc: + raise LLMProviderError(self._format_http_error(exc)) from exc + + @staticmethod + async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]: + """SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`.""" + async for line in response.aiter_lines(): + if not line or not line.startswith("data:"): + continue # lignes vides ou commentaires keep-alive (`: ...`) + data = line[len("data:"):].strip() + if data == "[DONE]": + return + try: + obj = json.loads(data) + except json.JSONDecodeError: + continue + choices = obj.get("choices") + if not choices: + continue + delta = choices[0].get("delta") or {} + content = delta.get("content") + if content: + yield content + + def _format_http_error(self, exc: httpx.HTTPError) -> str: + """Message lisible (timeout, quota 429, crédits 402, modèle inconnu…).""" + if isinstance(exc, httpx.TimeoutException): + return ( + f"Erreur {self._provider_label} : délai dépassé (timeout {self._timeout}s). " + "Le modèle a mis trop de temps — réduis la taille des morceaux d'import ou " + "augmente le timeout." + ) + detail = str(exc) or exc.__class__.__name__ + return f"Erreur {self._provider_label} ({exc.__class__.__name__}) : {detail}" diff --git a/brain/app/infrastructure/gemini_adapter.py b/brain/app/infrastructure/gemini_adapter.py index e6d7a2f..fa670a8 100644 --- a/brain/app/infrastructure/gemini_adapter.py +++ b/brain/app/infrastructure/gemini_adapter.py @@ -1,194 +1,66 @@ """Adapter Google Gemini — implémente les ports LLMProvider / LLMChatProvider. Gemini expose un endpoint COMPATIBLE OpenAI -(POST {base}/openai/chat/completions, SSE), donc cet adapter est un client -"OpenAI-compatible" — même structure que les adapters OpenRouter / Mistral. +(POST {base}/openai/chat/completions, SSE) : client "OpenAI-compatible" qui hérite +de BaseOpenAICompatibleAdapter et ne fournit que ses spécificités (dont un message +dédié quand Google refuse la clé en 401/403). Tier GRATUIT : clé API sur aistudio.google.com (sans CB). Atout majeur pour l'extraction de PDF : un CONTEXTE de ~1M tokens → un livre entier tient en 1-2 -appels, donc quasi aucun morceau perdu et peu de requêtes (limites jamais -atteintes). Modèle conseillé : `gemini-2.0-flash` (rapide, gros contexte, fidèle). +appels. Modèle conseillé : `gemini-2.0-flash` (rapide, gros contexte, fidèle). """ from __future__ import annotations -import asyncio -import json -import logging -from typing import AsyncIterator - -import httpx - from app.core.config import Settings -from app.domain.models import ChatMessage -from app.domain.ports import LLMGenerationTimeout, LLMProviderError - -logger = logging.getLogger(__name__) - -_API_URL = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions" - -# Délai max pour le PREMIER token de contenu (échec rapide si le modèle ne produit -# rien). Gemini répond vite ; 120s est large. -_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0 +from app.domain.ports import LLMProviderError +from app.infrastructure.base_openai_adapter import ( + _FIRST_TOKEN_TIMEOUT_SECONDS, + BaseOpenAICompatibleAdapter, +) -class GeminiLLMProvider: +class GeminiLLMProvider(BaseOpenAICompatibleAdapter): """Adapter Gemini (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider.""" + _provider_label = "Gemini" + _api_url = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions" + _supports_json_object = True + def __init__(self, settings: Settings) -> None: if not settings.gemini_api_key: raise LLMProviderError( "Clé API Gemini manquante. Configure-la depuis l'écran Paramètres " "(clé gratuite sur aistudio.google.com)." ) - self._api_key = settings.gemini_api_key - self._model = settings.gemini_model - self._timeout = settings.llm_timeout_seconds - - def _headers(self) -> dict[str, str]: - return { - "Authorization": f"Bearer {self._api_key}", - "Content-Type": "application/json", - "Accept": "application/json", - } - - async def generate( - self, - prompt: str, - *, - output_format: str | None = None, - temperature: float | None = None, - ) -> str: - """One-shot via streaming (puis recollage), avec garde-fous au temps écoulé.""" - return await self._collect_with_timeouts( - [ChatMessage(role="user", content=prompt)], temperature, output_format + super().__init__( + settings.gemini_api_key, settings.gemini_model, settings.llm_timeout_seconds ) - async def _collect_with_timeouts( - self, - messages: list[ChatMessage], - temperature: float | None, - output_format: str | None, - ) -> str: - """Collecte le stream avec deux garde-fous : 1er token borné (échec rapide - si rien ne sort) + ceiling global `self._timeout`.""" - async def _collect() -> str: - chunks: list[str] = [] - agen = self._stream(messages, None, temperature, output_format) - try: - while True: - first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None - try: - token = await asyncio.wait_for(agen.__anext__(), timeout=first) - except StopAsyncIteration: - break - except asyncio.TimeoutError: - raise LLMProviderError( - f"Erreur Gemini : aucun contenu produit en " - f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s. Réessayez ou vérifiez " - "votre quota gratuit." - ) - chunks.append(token) - finally: - await agen.aclose() - return "".join(chunks) + def _headers(self) -> dict[str, str]: + return {**super()._headers(), "Accept": "application/json"} - try: - return await asyncio.wait_for(_collect(), timeout=self._timeout) - except asyncio.TimeoutError as exc: - raise LLMGenerationTimeout( - f"Erreur Gemini : génération non terminée en {self._timeout}s. Réduisez la " - "taille des morceaux d'import ou augmentez le timeout." - ) from exc + def _first_token_timeout_message(self) -> str: + return ( + f"Erreur Gemini : aucun contenu produit en " + f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s. Réessayez ou vérifiez " + "votre quota gratuit." + ) - async def stream_chat( - self, - messages: list[ChatMessage], - *, - system_prompt: str | None = None, - temperature: float | None = None, - ) -> AsyncIterator[str]: - async for token in self._stream(messages, system_prompt, temperature): - yield token + def _generation_timeout_message(self) -> str: + return ( + f"Erreur Gemini : génération non terminée en {self._timeout}s. Réduisez la " + "taille des morceaux d'import ou augmentez le timeout." + ) - async def _stream( - self, - messages: list[ChatMessage], - system_prompt: str | None, - temperature: float | None, - output_format: str | None = None, - ) -> AsyncIterator[str]: - payload_messages: list[dict[str, str]] = [] - if system_prompt: - payload_messages.append({"role": "system", "content": system_prompt}) - for m in messages: - payload_messages.append({"role": m.role, "content": m.content}) - - body: dict[str, object] = { - "model": self._model, - "messages": payload_messages, - "stream": True, - } - if temperature is not None: - body["temperature"] = temperature - # Mode JSON natif (supporté par l'endpoint OpenAI-compatible de Gemini) : - # supprime fences ```json et JSON invalide, principale cause de morceaux - # ignorés. Un SCHÉMA (dict) est traduit en json_object : suffisant, les - # grands modèles cloud respectent la structure demandée par le prompt. - if output_format is not None: - body["response_format"] = {"type": "json_object"} - - async with httpx.AsyncClient(timeout=self._timeout) as client: - try: - async with client.stream( - "POST", _API_URL, headers=self._headers(), json=body - ) as response: - if response.status_code >= 400: - detail = (await response.aread()).decode("utf-8", "replace").strip() - # 401/403 = clé rejetée par GOOGLE (pas un problème LoreMind) : - # message actionnable plutôt que le JSON brut de l'API. - if response.status_code in (401, 403): - raise LLMProviderError( - "Erreur Gemini : clé API refusée par Google " - f"(HTTP {response.status_code}). Vérifiez que la clé vient bien " - "de aistudio.google.com (« Get API key ») et qu'elle n'a pas de " - "restrictions (API ou adresse IP) dans la Google Cloud Console. " - f"Détail : {detail[:300]}" - ) - raise LLMProviderError( - f"Erreur Gemini (HTTP {response.status_code})" - + (f" : {detail[:500]}" if detail else "") - ) - async for token in self._parse_sse(response): - yield token - except httpx.HTTPError as exc: - raise LLMProviderError(self._format_http_error(exc)) from exc - - @staticmethod - async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]: - """SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`.""" - async for line in response.aiter_lines(): - if not line or not line.startswith("data:"): - continue - data = line[len("data:"):].strip() - if data == "[DONE]": - return - try: - obj = json.loads(data) - except json.JSONDecodeError: - continue - choices = obj.get("choices") - if not choices: - continue - delta = choices[0].get("delta") or {} - content = delta.get("content") - if content: - yield content - - def _format_http_error(self, exc: httpx.HTTPError) -> str: - if isinstance(exc, httpx.TimeoutException): - return ( - f"Erreur Gemini : délai dépassé (timeout {self._timeout}s). Le modèle a " - "mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout." + def _error_for_status(self, status_code: int, detail: str) -> LLMProviderError: + # 401/403 = clé rejetée par GOOGLE (pas un problème LoreMind) : message + # actionnable plutôt que le JSON brut de l'API. + if status_code in (401, 403): + return LLMProviderError( + "Erreur Gemini : clé API refusée par Google " + f"(HTTP {status_code}). Vérifiez que la clé vient bien " + "de aistudio.google.com (« Get API key ») et qu'elle n'a pas de " + "restrictions (API ou adresse IP) dans la Google Cloud Console. " + f"Détail : {detail[:300]}" ) - detail = str(exc) or exc.__class__.__name__ - return f"Erreur Gemini ({exc.__class__.__name__}) : {detail}" + return super()._error_for_status(status_code, detail) diff --git a/brain/app/infrastructure/mistral_adapter.py b/brain/app/infrastructure/mistral_adapter.py index 33d5018..c871623 100644 --- a/brain/app/infrastructure/mistral_adapter.py +++ b/brain/app/infrastructure/mistral_adapter.py @@ -1,195 +1,48 @@ """Adapter Mistral — implémente les ports LLMProvider / LLMChatProvider. Mistral (La Plateforme) expose l'API OpenAI standard (POST {base}/chat/completions, -SSE), donc cet adapter est un client "OpenAI-compatible" — même structure que -l'adapter OpenRouter. Le `generate` one-shot passe par le streaming (puis -recollage) avec un timeout au temps écoulé pour ne jamais pendre à l'infini. +SSE) : client "OpenAI-compatible" qui hérite de BaseOpenAICompatibleAdapter et ne +fournit que ses spécificités. Tier GRATUIT : compte sur console.mistral.ai (tier « Experiment »), clé API à coller dans l'écran Paramètres. Modèles conseillés pour l'extraction : un grand contexte fidèle comme `mistral-large-latest` (128k) ou `mistral-small-latest`. + +Mode JSON natif : TOUS les modèles Mistral le supportent (`_supports_json_object`). """ from __future__ import annotations -import asyncio -import json -import logging -from typing import AsyncIterator - -import httpx - from app.core.config import Settings -from app.domain.models import ChatMessage -from app.domain.ports import LLMGenerationTimeout, LLMProviderError - -logger = logging.getLogger(__name__) - -_API_URL = "https://api.mistral.ai/v1/chat/completions" - -# Délai max pour le PREMIER token de contenu (échec rapide si le modèle est en file -# d'attente et n'envoie que des keep-alive). Généreux car la file d'un tier gratuit -# peut être longue. -_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0 +from app.domain.ports import LLMProviderError +from app.infrastructure.base_openai_adapter import ( + _FIRST_TOKEN_TIMEOUT_SECONDS, + BaseOpenAICompatibleAdapter, +) -class MistralLLMProvider: +class MistralLLMProvider(BaseOpenAICompatibleAdapter): """Adapter Mistral (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider.""" + _provider_label = "Mistral" + _api_url = "https://api.mistral.ai/v1/chat/completions" + _supports_json_object = True + def __init__(self, settings: Settings) -> None: if not settings.mistral_api_key: raise LLMProviderError( "Clé API Mistral manquante. Configure-la depuis l'écran Paramètres." ) - self._api_key = settings.mistral_api_key - self._model = settings.mistral_model - self._timeout = settings.llm_timeout_seconds - - def _headers(self) -> dict[str, str]: - return { - "Authorization": f"Bearer {self._api_key}", - "Content-Type": "application/json", - "Accept": "application/json", - } - - async def generate( - self, - prompt: str, - *, - output_format: str | None = None, - temperature: float | None = None, - ) -> str: - """One-shot via streaming (puis recollage) pour robustesse sur longues sorties. - - Timeout au TEMPS ÉCOULÉ (asyncio) en plus du timeout réseau d'httpx : - si le provider envoyait des keep-alive sans contenu, l'appel pendrait à - l'infini. Ici on coupe net après `self._timeout` secondes. - """ - return await self._collect_with_timeouts( - [ChatMessage(role="user", content=prompt)], temperature, output_format + super().__init__( + settings.mistral_api_key, settings.mistral_model, settings.llm_timeout_seconds ) - async def _collect_with_timeouts( - self, - messages: list[ChatMessage], - temperature: float | None, - output_format: str | None, - ) -> str: - """Collecte le stream avec deux garde-fous au temps écoulé : 1er token borné - (file d'attente → échec rapide) + ceiling global `self._timeout`.""" - async def _collect() -> str: - chunks: list[str] = [] - agen = self._stream(messages, None, temperature, output_format) - try: - while True: - first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None - try: - token = await asyncio.wait_for(agen.__anext__(), timeout=first) - except StopAsyncIteration: - break - except asyncio.TimeoutError: - raise LLMProviderError( - f"Erreur Mistral : aucun contenu produit en " - f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle est probablement " - "en file d'attente (tier gratuit, 2 req/min). Réessayez plus tard ou " - "choisissez un modèle plus disponible." - ) - chunks.append(token) - finally: - await agen.aclose() - return "".join(chunks) + def _headers(self) -> dict[str, str]: + return {**super()._headers(), "Accept": "application/json"} - try: - return await asyncio.wait_for(_collect(), timeout=self._timeout) - except asyncio.TimeoutError as exc: - raise LLMGenerationTimeout( - f"Erreur Mistral : génération non terminée en {self._timeout}s. Réduisez la " - "taille des morceaux d'import, augmentez le timeout, ou changez de modèle." - ) from exc - - async def stream_chat( - self, - messages: list[ChatMessage], - *, - system_prompt: str | None = None, - temperature: float | None = None, - ) -> AsyncIterator[str]: - async for token in self._stream(messages, system_prompt, temperature): - yield token - - async def _stream( - self, - messages: list[ChatMessage], - system_prompt: str | None, - temperature: float | None, - output_format: str | None = None, - ) -> AsyncIterator[str]: - payload_messages: list[dict[str, str]] = [] - if system_prompt: - payload_messages.append({"role": "system", "content": system_prompt}) - for m in messages: - payload_messages.append({"role": m.role, "content": m.content}) - - body: dict[str, object] = { - "model": self._model, - "messages": payload_messages, - "stream": True, - } - if temperature is not None: - body["temperature"] = temperature - # Mode JSON natif : TOUS les modèles Mistral le supportent → plus de fences - # ```json ni de JSON invalide (retours à la ligne bruts dans les chaînes), - # principale cause de morceaux d'import ignorés. Un SCHÉMA (dict) est - # traduit en json_object : suffisant ici, les grands modèles cloud - # respectent la structure demandée par le prompt. - if output_format is not None: - body["response_format"] = {"type": "json_object"} - - async with httpx.AsyncClient(timeout=self._timeout) as client: - try: - async with client.stream( - "POST", _API_URL, headers=self._headers(), json=body - ) as response: - if response.status_code >= 400: - # En streaming le corps n'est pas lu automatiquement : on le - # lit pour exposer le détail de Mistral (modèle inconnu, clé - # invalide 401, quota 429…), sinon on n'a que le code HTTP nu. - detail = (await response.aread()).decode("utf-8", "replace").strip() - raise LLMProviderError( - f"Erreur Mistral (HTTP {response.status_code})" - + (f" : {detail[:500]}" if detail else "") - ) - async for token in self._parse_sse(response): - yield token - except httpx.HTTPError as exc: - raise LLMProviderError(self._format_http_error(exc)) from exc - - @staticmethod - async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]: - """SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`.""" - async for line in response.aiter_lines(): - if not line or not line.startswith("data:"): - continue # lignes vides ou keep-alive (`: ...`) - data = line[len("data:"):].strip() - if data == "[DONE]": - return - try: - obj = json.loads(data) - except json.JSONDecodeError: - continue - choices = obj.get("choices") - if not choices: - continue - delta = choices[0].get("delta") or {} - content = delta.get("content") - if content: - yield content - - def _format_http_error(self, exc: httpx.HTTPError) -> str: - """Message lisible (timeout, quota 429, clé invalide 401, modèle inconnu…).""" - if isinstance(exc, httpx.TimeoutException): - return ( - f"Erreur Mistral : délai dépassé (timeout {self._timeout}s). Le modèle a " - "mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout." - ) - detail = str(exc) or exc.__class__.__name__ - return f"Erreur Mistral ({exc.__class__.__name__}) : {detail}" + def _first_token_timeout_message(self) -> str: + return ( + f"Erreur Mistral : aucun contenu produit en " + f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle est probablement " + "en file d'attente (tier gratuit, 2 req/min). Réessayez plus tard ou " + "choisissez un modèle plus disponible." + ) diff --git a/brain/app/infrastructure/openrouter_adapter.py b/brain/app/infrastructure/openrouter_adapter.py index 0dc56c1..16b63ba 100644 --- a/brain/app/infrastructure/openrouter_adapter.py +++ b/brain/app/infrastructure/openrouter_adapter.py @@ -1,205 +1,57 @@ """Adapter OpenRouter — implémente les ports LLMProvider / LLMChatProvider. OpenRouter expose l'API OpenAI standard (POST {base}/chat/completions, SSE), donc -cet adapter est en réalité un client "OpenAI-compatible". Le `generate` one-shot -passe lui aussi par le streaming (puis recollage) pour éviter les coupures de -passerelle sur les longues générations (cf. 1min.ai / Cloudflare 524). +cet adapter est un client "OpenAI-compatible" : il hérite de toute la mécanique de +BaseOpenAICompatibleAdapter et ne fournit que ses spécificités (URL, en-têtes +d'attribution, messages, lecture de config). Modèles GRATUITS : utiliser un id finissant par `:free` (ex. `meta-llama/llama-3.3-70b-instruct:free`) ou le routeur `openrouter/free` (défaut) qui choisit automatiquement un modèle gratuit — aucun crédit consommé. + +NB : on n'impose PAS `response_format=json_object` (`_supports_json_object=False`). +Beaucoup de modèles/providers GRATUITS ne le supportent pas et renvoient une +réponse VIDE. On laisse le modèle répondre librement ; l'extraction JSON en aval +(load_json_object + nettoyage du raisonnement) récupère le JSON dans la prose. """ from __future__ import annotations -import asyncio -import json -import logging -from typing import AsyncIterator - -import httpx - from app.core.config import Settings -from app.domain.models import ChatMessage - -logger = logging.getLogger(__name__) -from app.domain.ports import LLMGenerationTimeout, LLMProviderError - -_API_URL = "https://openrouter.ai/api/v1/chat/completions" - -# Délai max pour le PREMIER token de contenu. Un modèle gratuit "en file d'attente" -# n'envoie que des keep-alive (aucun contenu) → on échoue vite et clairement au lieu -# de pendre. Généreux (2 min) car la file d'attente d'un tier gratuit peut être longue. -_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0 +from app.domain.ports import LLMProviderError +from app.infrastructure.base_openai_adapter import ( + _FIRST_TOKEN_TIMEOUT_SECONDS, + BaseOpenAICompatibleAdapter, +) -class OpenRouterLLMProvider: +class OpenRouterLLMProvider(BaseOpenAICompatibleAdapter): """Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider.""" + _provider_label = "OpenRouter" + _api_url = "https://openrouter.ai/api/v1/chat/completions" + _supports_json_object = False + def __init__(self, settings: Settings) -> None: if not settings.openrouter_api_key: raise LLMProviderError( "Clé API OpenRouter manquante. Configure-la depuis l'écran Paramètres." ) - self._api_key = settings.openrouter_api_key - self._model = settings.openrouter_model - self._timeout = settings.llm_timeout_seconds + super().__init__( + settings.openrouter_api_key, settings.openrouter_model, settings.llm_timeout_seconds + ) def _headers(self) -> dict[str, str]: return { - "Authorization": f"Bearer {self._api_key}", - "Content-Type": "application/json", + **super()._headers(), # Attribution facultative (classement OpenRouter) — sans impact fonctionnel. "HTTP-Referer": "https://loremind.app", "X-Title": "LoreMind", } - async def generate( - self, - prompt: str, - *, - output_format: str | None = None, - temperature: float | None = None, - ) -> str: - """One-shot via streaming (puis recollage) pour robustesse sur longues sorties. - - Timeout au TEMPS ÉCOULÉ (asyncio) en plus du timeout réseau d'httpx : un - modèle gratuit saturé/en file d'attente envoie des keep-alive (`: OPENROUTER - PROCESSING`) mais AUCUN contenu → httpx ne déclenche jamais son read-timeout - (des octets arrivent) et l'appel pendrait à l'infini. Ici on coupe net après - `self._timeout` secondes, quoi qu'il arrive. - """ - return await self._collect_with_timeouts( - [ChatMessage(role="user", content=prompt)], temperature, output_format, "OpenRouter" + def _first_token_timeout_message(self) -> str: + return ( + f"Erreur OpenRouter : aucun contenu produit en " + f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle gratuit est " + "probablement en file d'attente / saturé. Réessayez plus tard ou " + "choisissez un autre modèle (1min.ai, ou payant)." ) - - async def _collect_with_timeouts( - self, - messages: list[ChatMessage], - temperature: float | None, - output_format: str | None, - provider: str, - ) -> str: - """Collecte le stream avec DEUX garde-fous au temps écoulé : - - 1er token borné (`_FIRST_TOKEN_TIMEOUT_SECONDS`) : détecte un modèle bloqué - en file d'attente (que des keep-alive, aucun contenu) → échec rapide ; - - ceiling global (`self._timeout`) : génération qui ne se termine jamais. - Le timeout réseau d'httpx ne suffit pas : des keep-alive font 'arriver des - octets' et empêchent son read-timeout de se déclencher. - """ - async def _collect() -> str: - chunks: list[str] = [] - agen = self._stream(messages, None, temperature, output_format) - try: - while True: - # Borne SEULEMENT l'attente du 1er token (file d'attente) ; ensuite - # on laisse générer (le ceiling global couvre le reste). - first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None - try: - token = await asyncio.wait_for(agen.__anext__(), timeout=first) - except StopAsyncIteration: - break - except asyncio.TimeoutError: - raise LLMProviderError( - f"Erreur {provider} : aucun contenu produit en " - f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle gratuit est " - "probablement en file d'attente / saturé. Réessayez plus tard ou " - "choisissez un autre modèle (1min.ai, ou payant)." - ) - chunks.append(token) - finally: - await agen.aclose() - return "".join(chunks) - - try: - return await asyncio.wait_for(_collect(), timeout=self._timeout) - except asyncio.TimeoutError as exc: - raise LLMGenerationTimeout( - f"Erreur {provider} : génération non terminée en {self._timeout}s. Réduisez la " - "taille des morceaux d'import, augmentez le timeout, ou changez de modèle." - ) from exc - - async def stream_chat( - self, - messages: list[ChatMessage], - *, - system_prompt: str | None = None, - temperature: float | None = None, - ) -> AsyncIterator[str]: - async for token in self._stream(messages, system_prompt, temperature): - yield token - - async def _stream( - self, - messages: list[ChatMessage], - system_prompt: str | None, - temperature: float | None, - output_format: str | None = None, - ) -> AsyncIterator[str]: - payload_messages: list[dict[str, str]] = [] - if system_prompt: - payload_messages.append({"role": "system", "content": system_prompt}) - for m in messages: - payload_messages.append({"role": m.role, "content": m.content}) - - body: dict[str, object] = { - "model": self._model, - "messages": payload_messages, - "stream": True, - } - if temperature is not None: - body["temperature"] = temperature - # NB : on n'impose PAS `response_format=json_object`. Beaucoup de modèles/ - # providers GRATUITS ne le supportent pas et renvoient une réponse VIDE. - # On laisse le modèle répondre librement ; l'extraction JSON en aval - # (load_json_object + nettoyage du raisonnement) récupère le JSON dans la prose. - - async with httpx.AsyncClient(timeout=self._timeout) as client: - try: - async with client.stream( - "POST", _API_URL, headers=self._headers(), json=body - ) as response: - if response.status_code >= 400: - # En streaming, le corps n'est pas lu automatiquement : on le - # lit pour exposer le détail d'OpenRouter (ex. le 429 précise - # "free-models-per-day" vs "per-minute"), sinon on n'a que le - # code HTTP nu et le diagnostic est impossible. - detail = (await response.aread()).decode("utf-8", "replace").strip() - raise LLMProviderError( - f"Erreur OpenRouter (HTTP {response.status_code})" - + (f" : {detail[:500]}" if detail else "") - ) - async for token in self._parse_sse(response): - yield token - except httpx.HTTPError as exc: - raise LLMProviderError(self._format_http_error(exc)) from exc - - @staticmethod - async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]: - """SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`.""" - async for line in response.aiter_lines(): - if not line or not line.startswith("data:"): - continue # lignes vides ou commentaires keep-alive (`: ...`) - data = line[len("data:"):].strip() - if data == "[DONE]": - return - try: - obj = json.loads(data) - except json.JSONDecodeError: - continue - choices = obj.get("choices") - if not choices: - continue - delta = choices[0].get("delta") or {} - content = delta.get("content") - if content: - yield content - - def _format_http_error(self, exc: httpx.HTTPError) -> str: - """Message lisible (timeout, quota 429, crédits 402, modèle inconnu…).""" - if isinstance(exc, httpx.TimeoutException): - return ( - f"Erreur OpenRouter : délai dépassé (timeout {self._timeout}s). Le modèle a " - "mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout." - ) - detail = str(exc) or exc.__class__.__name__ - return f"Erreur OpenRouter ({exc.__class__.__name__}) : {detail}" diff --git a/brain/app/main.py b/brain/app/main.py index dd79092..6c72c15 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo app = FastAPI( title="LoreMind Brain", description="Backend IA pour la génération de contenu narratif.", - version="0.16.1", + version="0.16.2", ) logger = logging.getLogger(__name__) diff --git a/brain/pytest.ini b/brain/pytest.ini new file mode 100644 index 0000000..a45b1e2 --- /dev/null +++ b/brain/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +# Tests unitaires du brain. asyncio_mode=auto : les coroutines de test sont +# exécutées sans décorateur @pytest.mark.asyncio explicite. +asyncio_mode = auto +# Ajoute la racine du brain au sys.path pour `import app...` sans installation. +pythonpath = . +testpaths = tests diff --git a/brain/requirements-dev.txt b/brain/requirements-dev.txt new file mode 100644 index 0000000..1951979 --- /dev/null +++ b/brain/requirements-dev.txt @@ -0,0 +1,11 @@ +# Dépendances de TEST uniquement (non embarquées dans l'image / le bundle desktop). +# Installer avec : .venv/Scripts/python -m pip install -r requirements-dev.txt +-r requirements.txt + +pytest>=8,<9 +pytest-asyncio>=0.24,<1 +# Mock du transport httpx (intercepte les appels aux API LLM dans les tests). +respx>=0.21,<1 +# Couverture de tests (équivalent JaCoCo) : `pytest --cov=app --cov-report=html` +# → rapport HTML dans htmlcov/. La CI ajoute --cov-fail-under pour le plancher. +pytest-cov>=5,<7 diff --git a/brain/tests/test_adapt_campaign.py b/brain/tests/test_adapt_campaign.py new file mode 100644 index 0000000..20558f3 --- /dev/null +++ b/brain/tests/test_adapt_campaign.py @@ -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) diff --git a/brain/tests/test_chat.py b/brain/tests/test_chat.py new file mode 100644 index 0000000..f0c5e1c --- /dev/null +++ b/brain/tests/test_chat.py @@ -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 diff --git a/brain/tests/test_chunking.py b/brain/tests/test_chunking.py new file mode 100644 index 0000000..153624d --- /dev/null +++ b/brain/tests/test_chunking.py @@ -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 diff --git a/brain/tests/test_embedding_adapters.py b/brain/tests/test_embedding_adapters.py new file mode 100644 index 0000000..f31057b --- /dev/null +++ b/brain/tests/test_embedding_adapters.py @@ -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) diff --git a/brain/tests/test_generate_page.py b/brain/tests/test_generate_page.py new file mode 100644 index 0000000..da39815 --- /dev/null +++ b/brain/tests/test_generate_page.py @@ -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"} diff --git a/brain/tests/test_import_parsing.py b/brain/tests/test_import_parsing.py new file mode 100644 index 0000000..b30c433 --- /dev/null +++ b/brain/tests/test_import_parsing.py @@ -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 == {} diff --git a/brain/tests/test_import_status.py b/brain/tests/test_import_status.py new file mode 100644 index 0000000..9c02c65 --- /dev/null +++ b/brain/tests/test_import_status.py @@ -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() diff --git a/brain/tests/test_import_use_cases.py b/brain/tests/test_import_use_cases.py new file mode 100644 index 0000000..bb1382f --- /dev/null +++ b/brain/tests/test_import_use_cases.py @@ -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" diff --git a/brain/tests/test_language.py b/brain/tests/test_language.py new file mode 100644 index 0000000..eff40ef --- /dev/null +++ b/brain/tests/test_language.py @@ -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" diff --git a/brain/tests/test_llm_json.py b/brain/tests/test_llm_json.py new file mode 100644 index 0000000..9fe5791 --- /dev/null +++ b/brain/tests/test_llm_json.py @@ -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 . +""" +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 = 'je réfléchis { ] [ }{"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 diff --git a/brain/tests/test_llm_retry.py b/brain/tests/test_llm_retry.py new file mode 100644 index 0000000..82fbbe8 --- /dev/null +++ b/brain/tests/test_llm_retry.py @@ -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] diff --git a/brain/tests/test_models.py b/brain/tests/test_models.py new file mode 100644 index 0000000..90c17dd --- /dev/null +++ b/brain/tests/test_models.py @@ -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 == [] diff --git a/brain/tests/test_ollama_adapter.py b/brain/tests/test_ollama_adapter.py new file mode 100644 index 0000000..95409b9 --- /dev/null +++ b/brain/tests/test_ollama_adapter.py @@ -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"} diff --git a/brain/tests/test_onemin_adapter.py b/brain/tests/test_onemin_adapter.py new file mode 100644 index 0000000..eb42257 --- /dev/null +++ b/brain/tests/test_onemin_adapter.py @@ -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}) diff --git a/brain/tests/test_openai_compatible_adapters.py b/brain/tests/test_openai_compatible_adapters.py new file mode 100644 index 0000000..580e7fa --- /dev/null +++ b/brain/tests/test_openai_compatible_adapters.py @@ -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)) diff --git a/brain/tests/test_query_rewrite.py b/brain/tests/test_query_rewrite.py new file mode 100644 index 0000000..a49fadf --- /dev/null +++ b/brain/tests/test_query_rewrite.py @@ -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 diff --git a/brain/tests/test_rerank.py b/brain/tests/test_rerank.py new file mode 100644 index 0000000..725d8b9 --- /dev/null +++ b/brain/tests/test_rerank.py @@ -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"] diff --git a/brain/tests/test_section_merger.py b/brain/tests/test_section_merger.py new file mode 100644 index 0000000..d51f4e1 --- /dev/null +++ b/brain/tests/test_section_merger.py @@ -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" diff --git a/brain/tests/test_settings_store.py b/brain/tests/test_settings_store.py new file mode 100644 index 0000000..e53414f --- /dev/null +++ b/brain/tests/test_settings_store.py @@ -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() == {} diff --git a/brain/tests/test_streaming.py b/brain/tests/test_streaming.py new file mode 100644 index 0000000..d1819ff --- /dev/null +++ b/brain/tests/test_streaming.py @@ -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)) diff --git a/brain/tests/test_tree_merger.py b/brain/tests/test_tree_merger.py new file mode 100644 index 0000000..1b9cc90 --- /dev/null +++ b/brain/tests/test_tree_merger.py @@ -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" diff --git a/brain/tests/test_vector_store.py b/brain/tests/test_vector_store.py new file mode 100644 index 0000000..398d290 --- /dev/null +++ b/brain/tests/test_vector_store.py @@ -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 diff --git a/core/pom.xml b/core/pom.xml index 6ba2875..753c790 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,7 +14,7 @@ com.loremind loremind-core - 0.16.1 + 0.16.2 LoreMind Core Backend Core - Architecture Hexagonale @@ -195,6 +195,31 @@ report + + + check + test + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.60 + + + + + + diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java index b147899..687e919 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java @@ -14,7 +14,6 @@ import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter; import com.loremind.infrastructure.web.config.UserLanguageHolder; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; -import org.springframework.core.io.ByteArrayResource; import org.springframework.http.MediaType; import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.http.codec.ServerSentEvent; @@ -23,7 +22,6 @@ import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; -import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; @@ -43,7 +41,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { new ParameterizedTypeReference<>() {}; private final WebClient webClient; - private final ObjectMapper objectMapper; + private final BrainSseImportSupport sse; private final long importTimeoutSeconds; public BrainCampaignImportClient( @@ -52,7 +50,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { @Value("${brain.base-url}") String baseUrl, @Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) { this.webClient = webClientBuilder.baseUrl(baseUrl).build(); - this.objectMapper = objectMapper; + this.sse = new BrainSseImportSupport(objectMapper); this.importTimeoutSeconds = importTimeoutSeconds; } @@ -67,7 +65,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { Consumer onError) { MultipartBodyBuilder parts = new MultipartBodyBuilder(); - parts.part("file", filePart(pdfBytes, filename)) + parts.part("file", sse.filePart(pdfBytes, filename, "campaign.pdf")) .filename(filename == null || filename.isBlank() ? "campaign.pdf" : filename); Flux> flux = webClient.post() @@ -83,31 +81,16 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { int[] ocrPageCount = {0}; boolean[] terminated = {false}; - try { - flux - .timeout(Duration.ofSeconds(importTimeoutSeconds)) - .doOnNext(sse -> handleEvent( - sse, pageCount, ocrPageCount, terminated, - onProgress, onHeartbeat, onStatus, onDone, onError)) - .blockLast(); - if (!terminated[0]) { - onError.accept(new CampaignImportException( - "Le flux d'import s'est interrompu avant la fin.")); - } - } catch (Exception e) { - if (!terminated[0]) { - // On EXPOSE la cause réelle (type + message) : sans ça, le diagnostic - // est impossible (timeout WebClient, connexion coupée, réponse non-2xx…). - String cause = e.getClass().getSimpleName() - + (e.getMessage() != null ? " — " + e.getMessage() : ""); - onError.accept(new CampaignImportException( - "Erreur lors du streaming d'import depuis le Brain : " + cause, e)); - } - } + sse.runStream( + flux, importTimeoutSeconds, terminated, + event -> handleEvent( + event, pageCount, ocrPageCount, terminated, + onProgress, onHeartbeat, onStatus, onDone, onError), + onError, CampaignImportException::new); } private void handleEvent( - ServerSentEvent sse, + ServerSentEvent ssEvent, int[] pageCount, int[] ocrPageCount, boolean[] terminated, @@ -117,8 +100,8 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { Consumer onDone, Consumer onError) { - String event = sse.event(); - String data = sse.data() == null ? "" : sse.data(); + String event = ssEvent.event(); + String data = ssEvent.data() == null ? "" : ssEvent.data(); if ("heartbeat".equals(event)) { // Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au @@ -129,23 +112,17 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { if ("status".equals(event)) { // Message d'attente lisible (retry sur fournisseur saturé, morceau // re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs. - onStatus.accept(readMessage(data)); + onStatus.accept(sse.readMessage(data)); return; } if ("chunk_failed".equals(event)) { - JsonNode node = readJson(data); - String msg = node != null && node.hasNonNull("message") - ? node.get("message").asText() : ""; - int current = node != null ? node.path("current").asInt() : 0; - int total = node != null ? node.path("total").asInt() : 0; - onStatus.accept("Morceau " + current + "/" + total + " ignoré" - + (msg.isEmpty() ? "." : " : " + msg)); + onStatus.accept(sse.chunkFailedStatus(data)); return; } if ("error".equals(event)) { terminated[0] = true; onError.accept(new CampaignImportException( - "Le Brain a signalé une erreur : " + readMessage(data))); + "Le Brain a signalé une erreur : " + sse.readMessage(data))); return; } if ("extracting".equals(event)) { @@ -153,7 +130,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { return; } - JsonNode node = readJson(data); + JsonNode node = sse.readJson(data); if (node == null) return; if ("start".equals(event)) { @@ -246,33 +223,8 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { // --- Helpers ------------------------------------------------------------- - private ByteArrayResource filePart(byte[] pdfBytes, String filename) { - return new ByteArrayResource(pdfBytes) { - @Override - public String getFilename() { - return (filename == null || filename.isBlank()) ? "campaign.pdf" : filename; - } - }; - } - private static String text(JsonNode node, String field) { JsonNode v = node.path(field); return v.isMissingNode() || v.isNull() ? "" : v.asText(); } - - private JsonNode readJson(String data) { - try { - return objectMapper.readTree(data); - } catch (Exception e) { - return null; - } - } - - private String readMessage(String data) { - JsonNode node = readJson(data); - if (node != null && node.hasNonNull("message")) { - return node.get("message").asText(); - } - return data; - } } diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java index 8ae190f..7cbaa35 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java @@ -108,9 +108,7 @@ public class BrainChatPayloadBuilder { Map map = new LinkedHashMap<>(); map.put("name", q.name()); map.put("arc_name", q.arcName()); - if (q.description() != null && !q.description().isBlank()) { - map.put("description", q.description()); - } + putIfText(map, "description", q.description()); return map; } @@ -121,18 +119,14 @@ public class BrainChatPayloadBuilder { if (e.occurredAt() != null) { map.put("occurred_at", e.occurredAt().toString()); } - if (e.sourceSessionName() != null && !e.sourceSessionName().isBlank()) { - map.put("source_session_name", e.sourceSessionName()); - } + putIfText(map, "source_session_name", e.sourceSessionName()); return map; } private Map gameSystemContextToMap(GameSystemContext gs) { Map map = new LinkedHashMap<>(); map.put("system_name", gs.systemName()); - if (gs.systemDescription() != null && !gs.systemDescription().isBlank()) { - map.put("system_description", gs.systemDescription()); - } + putIfText(map, "system_description", gs.systemDescription()); map.put("sections", gs.sections() != null ? gs.sections() : Map.of()); return map; } @@ -211,18 +205,14 @@ public class BrainChatPayloadBuilder { private Map characterSummaryToMap(CharacterSummary c) { Map map = new LinkedHashMap<>(); map.put("name", c.name()); - if (c.snippet() != null && !c.snippet().isBlank()) { - map.put("snippet", c.snippet()); - } + putIfText(map, "snippet", c.snippet()); return map; } private Map npcSummaryToMap(NpcSummary n) { Map map = new LinkedHashMap<>(); map.put("name", n.name()); - if (n.snippet() != null && !n.snippet().isBlank()) { - map.put("snippet", n.snippet()); - } + putIfText(map, "snippet", n.snippet()); return map; } @@ -300,9 +290,7 @@ public class BrainChatPayloadBuilder { Map map = new LinkedHashMap<>(); map.put("label", b.label()); map.put("target_scene_name", b.targetSceneName()); - if (b.condition() != null && !b.condition().isBlank()) { - map.put("condition", b.condition()); - } + putIfText(map, "condition", b.condition()); return map; } @@ -310,14 +298,14 @@ public class BrainChatPayloadBuilder { Map map = new LinkedHashMap<>(); map.put("name", r.name()); if (r.floor() != null) map.put("floor", r.floor()); - if (r.description() != null && !r.description().isBlank()) map.put("description", r.description()); - if (r.enemies() != null && !r.enemies().isBlank()) map.put("enemies", r.enemies()); + putIfText(map, "description", r.description()); + putIfText(map, "enemies", r.enemies()); if (r.branches() != null && !r.branches().isEmpty()) { map.put("branches", r.branches().stream().map(b -> { Map bm = new LinkedHashMap<>(); bm.put("label", b.label()); bm.put("target_room_name", b.targetRoomName()); - if (b.condition() != null && !b.condition().isBlank()) bm.put("condition", b.condition()); + putIfText(bm, "condition", b.condition()); return bm; }).collect(Collectors.toList())); } @@ -331,4 +319,15 @@ public class BrainChatPayloadBuilder { map.put("fields", ne.fields()); return map; } + + /** + * Ajoute {@code key → value} uniquement si {@code value} est une chaîne non + * nulle et non blanche. Centralise l'omission des champs Optional « texte » + * pour s'aligner sur le schéma Pydantic du Brain (champ absent si vide). + */ + private static void putIfText(Map map, String key, String value) { + if (value != null && !value.isBlank()) { + map.put(key, value); + } + } } diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java index c9f7728..c0bdb41 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java @@ -10,7 +10,6 @@ import com.loremind.infrastructure.web.config.UserLanguageHolder; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; -import org.springframework.core.io.ByteArrayResource; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -26,7 +25,6 @@ import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; -import java.time.Duration; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -56,7 +54,7 @@ public class BrainRulesImportClient implements RulesPdfImporter { private final RestTemplate restTemplate; private final WebClient webClient; - private final ObjectMapper objectMapper; + private final BrainSseImportSupport sse; private final String baseUrl; private final long importTimeoutSeconds; @@ -68,7 +66,7 @@ public class BrainRulesImportClient implements RulesPdfImporter { @Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) { this.restTemplate = restTemplate; this.webClient = webClientBuilder.baseUrl(baseUrl).build(); - this.objectMapper = objectMapper; + this.sse = new BrainSseImportSupport(objectMapper); this.baseUrl = baseUrl; this.importTimeoutSeconds = importTimeoutSeconds; } @@ -81,7 +79,7 @@ public class BrainRulesImportClient implements RulesPdfImporter { headers.setContentType(MediaType.MULTIPART_FORM_DATA); MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("file", filePart(pdfBytes, filename)); + body.add("file", sse.filePart(pdfBytes, filename, "rules.pdf")); HttpEntity> entity = new HttpEntity<>(body, headers); try { @@ -121,7 +119,7 @@ public class BrainRulesImportClient implements RulesPdfImporter { Consumer onError) { MultipartBodyBuilder parts = new MultipartBodyBuilder(); - parts.part("file", filePart(pdfBytes, filename)) + parts.part("file", sse.filePart(pdfBytes, filename, "rules.pdf")) .filename(filename == null || filename.isBlank() ? "rules.pdf" : filename); Flux> flux = webClient.post() @@ -139,33 +137,16 @@ public class BrainRulesImportClient implements RulesPdfImporter { int[] ocrPageCount = {0}; boolean[] terminated = {false}; - try { - flux - .timeout(Duration.ofSeconds(importTimeoutSeconds)) - .doOnNext(sse -> handleEvent( - sse, pageCount, ocrPageCount, terminated, - onProgress, onHeartbeat, onStatus, onDone, onError)) - .blockLast(); - // Flux terminé sans event done/error (ex: connexion coupée) → on signale. - if (!terminated[0]) { - onError.accept(new RulesImportException( - "Le flux d'import s'est interrompu avant la fin.")); - } - } catch (Exception e) { - if (!terminated[0]) { - // On EXPOSE la cause réelle (type + message) : sans ça, l'UI n'a qu'un - // message générique et le diagnostic est impossible (timeout WebClient, - // connexion coupée, réponse non-2xx du Brain, etc.). - String cause = e.getClass().getSimpleName() - + (e.getMessage() != null ? " — " + e.getMessage() : ""); - onError.accept(new RulesImportException( - "Erreur lors du streaming d'import depuis le Brain : " + cause, e)); - } - } + sse.runStream( + flux, importTimeoutSeconds, terminated, + event -> handleEvent( + event, pageCount, ocrPageCount, terminated, + onProgress, onHeartbeat, onStatus, onDone, onError), + onError, RulesImportException::new); } private void handleEvent( - ServerSentEvent sse, + ServerSentEvent ssEvent, int[] pageCount, int[] ocrPageCount, boolean[] terminated, @@ -175,8 +156,8 @@ public class BrainRulesImportClient implements RulesPdfImporter { Consumer onDone, Consumer onError) { - String event = sse.event(); - String data = sse.data() == null ? "" : sse.data(); + String event = ssEvent.event(); + String data = ssEvent.data() == null ? "" : ssEvent.data(); if ("heartbeat".equals(event)) { // Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au @@ -188,23 +169,17 @@ public class BrainRulesImportClient implements RulesPdfImporter { if ("status".equals(event)) { // Message d'attente lisible (retry sur fournisseur saturé, morceau // re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs. - onStatus.accept(readMessage(data)); + onStatus.accept(sse.readMessage(data)); return; } if ("chunk_failed".equals(event)) { - JsonNode node = readJson(data); - String msg = node != null && node.hasNonNull("message") - ? node.get("message").asText() : ""; - int current = node != null ? node.path("current").asInt() : 0; - int total = node != null ? node.path("total").asInt() : 0; - onStatus.accept("Morceau " + current + "/" + total + " ignoré" - + (msg.isEmpty() ? "." : " : " + msg)); + onStatus.accept(sse.chunkFailedStatus(data)); return; } if ("error".equals(event)) { terminated[0] = true; onError.accept(new RulesImportException( - "Le Brain a signalé une erreur : " + readMessage(data))); + "Le Brain a signalé une erreur : " + sse.readMessage(data))); return; } if ("extracting".equals(event)) { @@ -213,7 +188,7 @@ public class BrainRulesImportClient implements RulesPdfImporter { return; } - JsonNode node = readJson(data); + JsonNode node = sse.readJson(data); if (node == null) return; if ("start".equals(event)) { @@ -239,32 +214,6 @@ public class BrainRulesImportClient implements RulesPdfImporter { // --- Helpers ------------------------------------------------------------- - /** ByteArrayResource avec nom de fichier : sans nom, l'upload n'est pas vu comme un fichier. */ - private ByteArrayResource filePart(byte[] pdfBytes, String filename) { - return new ByteArrayResource(pdfBytes) { - @Override - public String getFilename() { - return (filename == null || filename.isBlank()) ? "rules.pdf" : filename; - } - }; - } - - private JsonNode readJson(String data) { - try { - return objectMapper.readTree(data); - } catch (Exception e) { - return null; // morceau de flux non-JSON inattendu : on l'ignore. - } - } - - private String readMessage(String data) { - JsonNode node = readJson(data); - if (node != null && node.hasNonNull("message")) { - return node.get("message").asText(); - } - return data; - } - private List toStringList(JsonNode array) { List out = new ArrayList<>(); if (array != null && array.isArray()) { diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainSseImportSupport.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainSseImportSupport.java new file mode 100644 index 0000000..17361c9 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainSseImportSupport.java @@ -0,0 +1,107 @@ +package com.loremind.infrastructure.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.codec.ServerSentEvent; +import reactor.core.publisher.Flux; + +import java.time.Duration; +import java.util.function.BiFunction; +import java.util.function.Consumer; + +/** + * Helper d'infrastructure mutualisé entre les clients d'import SSE du Brain + * ({@link BrainRulesImportClient} et {@link BrainCampaignImportClient}), qui + * partagent la même mécanique de transport (multipart → flux SSE WebClient) et + * les mêmes événements transverses (heartbeat / status / chunk_failed). + *

+ * Volontairement instancié en interne par chaque client (et non injecté) pour + * préserver leurs signatures de constructeur. Le parsing métier des événements + * {@code start} / {@code progress} / {@code done} reste propre à chaque client. + */ +final class BrainSseImportSupport { + + private final ObjectMapper objectMapper; + + BrainSseImportSupport(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** ByteArrayResource avec nom de fichier : sans nom, l'upload n'est pas vu comme un fichier. */ + ByteArrayResource filePart(byte[] bytes, String filename, String defaultName) { + return new ByteArrayResource(bytes) { + @Override + public String getFilename() { + return (filename == null || filename.isBlank()) ? defaultName : filename; + } + }; + } + + /** Parse le JSON, ou {@code null} si illisible (morceau de flux non-JSON inattendu : ignoré). */ + JsonNode readJson(String data) { + try { + return objectMapper.readTree(data); + } catch (Exception e) { + return null; + } + } + + /** Champ {@code message} du JSON, ou la {@code data} brute si non-JSON / champ absent. */ + String readMessage(String data) { + JsonNode node = readJson(data); + if (node != null && node.hasNonNull("message")) { + return node.get("message").asText(); + } + return data; + } + + /** Statut lisible « Morceau x/y ignoré[ : message] » depuis un payload {@code chunk_failed}. */ + String chunkFailedStatus(String data) { + JsonNode node = readJson(data); + String msg = node != null && node.hasNonNull("message") + ? node.get("message").asText() : ""; + int current = node != null ? node.path("current").asInt() : 0; + int total = node != null ? node.path("total").asInt() : 0; + return "Morceau " + current + "/" + total + " ignoré" + + (msg.isEmpty() ? "." : " : " + msg); + } + + /** + * Consomme le flux SSE jusqu'au bout ({@code blockLast}) en dispatchant chaque + * événement vers {@code handler}, et traduit les fins anormales en {@code onError} : + *

    + *
  • flux clos sans event {@code done}/{@code error} ({@code terminated[0]==false}) ;
  • + *
  • exception de transport (timeout WebClient, connexion coupée, réponse non-2xx) + * — la cause réelle (type + message) est exposée dans le message.
  • + *
+ * {@code errorFactory} fabrique l'exception de domaine à partir d'un message et + * d'une cause (nullable pour l'interruption silencieuse). + */ + void runStream( + Flux> flux, + long timeoutSeconds, + boolean[] terminated, + Consumer> handler, + Consumer onError, + BiFunction errorFactory) { + try { + flux + .timeout(Duration.ofSeconds(timeoutSeconds)) + .doOnNext(handler) + .blockLast(); + // Flux terminé sans event done/error (ex: connexion coupée) → on signale. + if (!terminated[0]) { + onError.accept(errorFactory.apply( + "Le flux d'import s'est interrompu avant la fin.", null)); + } + } catch (Exception e) { + if (!terminated[0]) { + String cause = e.getClass().getSimpleName() + + (e.getMessage() != null ? " — " + e.getMessage() : ""); + onError.accept(errorFactory.apply( + "Erreur lors du streaming d'import depuis le Brain : " + cause, e)); + } + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/IdRemapper.java b/core/src/main/java/com/loremind/infrastructure/transfer/IdRemapper.java new file mode 100644 index 0000000..93194ca --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/transfer/IdRemapper.java @@ -0,0 +1,81 @@ +package com.loremind.infrastructure.transfer; + +import com.loremind.domain.campaigncontext.ArcType; +import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.domain.campaigncontext.SceneBranch; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Logique de remapping des identifiants pour l'import en mode FUSION + * (cf. {@link ImportService}). + *

+ * Fonctions PURES (sans état ni I/O) : chaque entité importée reçoit un nouvel id, + * et toutes les références — FK {@code Long} comme refs faibles stockées en + * {@code String} — sont réécrites {@code oldId → newId} via les maps fournies. + * Une référence absente de la map est CONSERVÉE telle quelle (choix : ne jamais + * perdre d'info, ne jamais planter sur une référence hors périmètre d'export). + */ +final class IdRemapper { + + private IdRemapper() { + } + + /** Remap d'une FK Long : si absente de la map, on garde l'ancienne valeur ; {@code null → null}. */ + static Long remapId(Map map, Long oldId) { + if (oldId == null) return null; + return map.getOrDefault(oldId, oldId); + } + + /** Remap d'un id stocké en String ({@code "oldLong" → "newLong"}) via une map Long. */ + static String remapStringId(Map map, String oldId) { + if (oldId == null || oldId.isBlank()) return oldId; + try { + Long newId = map.get(Long.parseLong(oldId.trim())); + return newId != null ? String.valueOf(newId) : oldId; + } catch (NumberFormatException ex) { + return oldId; // pas un Long : on laisse tel quel + } + } + + static List remapStringList(Map map, List ids) { + if (ids == null) return null; + List out = new ArrayList<>(ids.size()); + for (String id : ids) out.add(remapStringId(map, id)); + return out; + } + + static List remapPrerequisites(Map chapterMap, List prereqs) { + if (prereqs == null) return null; + List out = new ArrayList<>(prereqs.size()); + for (Prerequisite p : prereqs) { + if (p instanceof Prerequisite.QuestCompleted qc) { + out.add(new Prerequisite.QuestCompleted(remapStringId(chapterMap, qc.questId()))); + } else { + out.add(p); // FlagSet / SessionReached : inchangés + } + } + return out; + } + + static List remapBranches(Map sceneMap, List branches) { + if (branches == null) return null; + List out = new ArrayList<>(branches.size()); + for (SceneBranch b : branches) { + out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition())); + } + return out; + } + + /** Parse un {@link ArcType} tolérant : type inconnu ou {@code null} → {@code LINEAR}. */ + static ArcType parseArcType(String type) { + if (type == null) return ArcType.LINEAR; + try { + return ArcType.valueOf(type); + } catch (IllegalArgumentException ex) { + return ArcType.LINEAR; + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ImageImporter.java b/core/src/main/java/com/loremind/infrastructure/transfer/ImageImporter.java new file mode 100644 index 0000000..ff290f9 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ImageImporter.java @@ -0,0 +1,91 @@ +package com.loremind.infrastructure.transfer; + +import com.loremind.domain.images.ports.ImageStorage; +import com.loremind.infrastructure.persistence.entity.ImageJpaEntity; +import com.loremind.infrastructure.persistence.jpa.ImageJpaRepository; +import com.loremind.infrastructure.transfer.dto.ContentExport; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayInputStream; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Réécriture des images lors d'un import (cf. {@link ImportService}). + *

+ * Les binaires sont stockés sous LEUR CLÉ D'ORIGINE (pas de remapping de clé) : + * une image dont la clé existe déjà est RÉUTILISÉE (pas de réupload), pour éviter + * les doublons quand on agrège plusieurs exports dans la même base. + */ +@Component +class ImageImporter { + + private final ImageJpaRepository imageRepo; + private final ImageStorage imageStorage; + + ImageImporter(ImageJpaRepository imageRepo, ImageStorage imageStorage) { + this.imageRepo = imageRepo; + this.imageStorage = imageStorage; + } + + /** + * Réécrit les binaires d'images (clé préservée) et leurs métadonnées. + * + * @param export contenu importé (source des métadonnées par clé) + * @param imageBinaries {@code storageKey → binaire} lus depuis le zip + * @param result compteurs d'images (uploadées / réutilisées) à incrémenter + */ + void importImages(ContentExport export, + Map imageBinaries, + ImportResult.Builder result) { + // Index des métadonnées d'image par clé (depuis le data.json). + Map metaByKey = new HashMap<>(); + for (ContentExport.ImageDto img : nullSafe(export.images())) { + if (img.storageKey() != null) metaByKey.put(img.storageKey(), img); + } + + for (Map.Entry bin : imageBinaries.entrySet()) { + String storageKey = bin.getKey(); + byte[] data = bin.getValue(); + if (imageRepo.findByStorageKey(storageKey).isPresent()) { + // Image déjà présente : on réutilise, pas de réupload (éviter doublon). + result.imageReused(); + continue; + } + ContentExport.ImageDto meta = metaByKey.get(storageKey); + String contentType = meta != null && meta.contentType() != null + ? meta.contentType() : guessContentType(storageKey); + long size = meta != null ? meta.sizeBytes() : data.length; + + imageStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length); + + ImageJpaEntity e = new ImageJpaEntity(); + e.setFilename(meta != null && meta.filename() != null + ? meta.filename() : fileNameOf(storageKey)); + e.setContentType(contentType); + e.setSizeBytes(size); + e.setStorageKey(storageKey); + imageRepo.save(e); + result.imageUploaded(); + } + } + + private static List nullSafe(List list) { + return list != null ? list : List.of(); + } + + private static String fileNameOf(String storageKey) { + int slash = storageKey.lastIndexOf('/'); + return slash >= 0 ? storageKey.substring(slash + 1) : storageKey; + } + + private static String guessContentType(String storageKey) { + String lower = storageKey.toLowerCase(); + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + return "application/octet-stream"; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java index 6f3b76b..60e1a45 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java @@ -1,9 +1,6 @@ package com.loremind.infrastructure.transfer; import com.fasterxml.jackson.databind.ObjectMapper; -import com.loremind.domain.campaigncontext.Prerequisite; -import com.loremind.domain.campaigncontext.SceneBranch; -import com.loremind.domain.images.ports.ImageStorage; import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter; import com.loremind.infrastructure.persistence.entity.*; import com.loremind.infrastructure.persistence.jpa.*; @@ -11,7 +8,6 @@ import com.loremind.infrastructure.transfer.dto.ContentExport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -65,8 +61,7 @@ public class ImportService { private final EnemyJpaRepository enemyRepo; private final ItemCatalogJpaRepository itemCatalogRepo; private final RandomTableJpaRepository randomTableRepo; - private final ImageJpaRepository imageRepo; - private final ImageStorage imageStorage; + private final ImageImporter imageImporter; private final ObjectMapper objectMapper; public ImportService(GameSystemJpaRepository gameSystemRepo, @@ -83,8 +78,7 @@ public class ImportService { EnemyJpaRepository enemyRepo, ItemCatalogJpaRepository itemCatalogRepo, RandomTableJpaRepository randomTableRepo, - ImageJpaRepository imageRepo, - ImageStorage imageStorage, + ImageImporter imageImporter, ObjectMapper objectMapper) { this.gameSystemRepo = gameSystemRepo; this.loreRepo = loreRepo; @@ -100,42 +94,20 @@ public class ImportService { this.enemyRepo = enemyRepo; this.itemCatalogRepo = itemCatalogRepo; this.randomTableRepo = randomTableRepo; - this.imageRepo = imageRepo; - this.imageStorage = imageStorage; + this.imageImporter = imageImporter; this.objectMapper = objectMapper; } @Transactional public ImportResult importZip(InputStream zipStream) { // 1. Parse du zip. - ContentExport export = null; - Map imageBinaries = new LinkedHashMap<>(); // storageKey -> binaire - try (ZipInputStream zip = new ZipInputStream(zipStream)) { - ZipEntry entry; - while ((entry = zip.getNextEntry()) != null) { - String name = entry.getName(); - if ("data.json".equals(name)) { - export = objectMapper.readValue(readAll(zip), ContentExport.class); - } else if (name.startsWith("images/") && !entry.isDirectory()) { - // La cle de stockage est le chemin sans le prefixe "images/" du zip, - // c'est-a-dire EXACTEMENT le storageKey d'origine ("images/UUID.ext"). - String storageKey = name.substring("images/".length()); - imageBinaries.put(storageKey, readAll(zip)); - } - // manifest.json : ignore a l'import (info seulement). - zip.closeEntry(); - } - } catch (IOException e) { - throw new UncheckedIOException("Echec de lecture du zip d'import", e); - } - if (export == null) { - throw new IllegalArgumentException("Archive invalide : data.json introuvable"); - } + ParsedArchive archive = parseArchive(zipStream); + ContentExport export = archive.export(); ImportResult.Builder result = new ImportResult.Builder(); // 2. Reecriture des images (cle preservee). - importImages(export, imageBinaries, result); + imageImporter.importImages(export, archive.imageBinaries(), result); // 3. Insertion top-down + maps de remapping. Map gameSystemMap = new HashMap<>(); @@ -184,7 +156,7 @@ public class ImportService { e.setName(d.name()); e.setIcon(d.icon()); e.setParentId(d.parentId()); // remappe plus bas - e.setLoreId(remapRequired(loreMap, d.loreId())); + e.setLoreId(IdRemapper.remapId(loreMap, d.loreId())); LoreNodeJpaEntity saved = loreNodeRepo.save(e); loreNodeMap.put(d.id(), saved.getId()); if (d.parentId() != null) loreNodesToFix.add(saved); @@ -195,7 +167,7 @@ public class ImportService { List templatesWithDefaultNode = new ArrayList<>(); for (ContentExport.TemplateDto d : nullSafe(export.templates())) { TemplateJpaEntity e = new TemplateJpaEntity(); - e.setLoreId(remapRequired(loreMap, d.loreId())); + e.setLoreId(IdRemapper.remapId(loreMap, d.loreId())); e.setName(d.name()); e.setDescription(d.description()); e.setDefaultNodeId(d.defaultNodeId()); // remappe plus bas @@ -208,9 +180,9 @@ public class ImportService { // -- Page (relatedPageIds remappe en 2e passe) for (ContentExport.PageDto d : nullSafe(export.pages())) { PageJpaEntity e = new PageJpaEntity(); - e.setLoreId(remapRequired(loreMap, d.loreId())); - e.setNodeId(remapRequired(loreNodeMap, d.nodeId())); - e.setTemplateId(remapNullable(templateMap, d.templateId())); + e.setLoreId(IdRemapper.remapId(loreMap, d.loreId())); + e.setNodeId(IdRemapper.remapId(loreNodeMap, d.nodeId())); + e.setTemplateId(IdRemapper.remapId(templateMap, d.templateId())); e.setTitle(d.title()); e.setValues(d.values()); e.setImageValues(d.imageValues()); @@ -240,9 +212,9 @@ public class ImportService { ArcJpaEntity e = new ArcJpaEntity(); e.setName(d.name()); e.setDescription(d.description()); - e.setCampaignId(remapRequired(campaignMap, d.campaignId())); + e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); e.setOrder(d.order()); - e.setType(parseArcType(d.type())); + e.setType(IdRemapper.parseArcType(d.type())); e.setIcon(d.icon()); e.setThemes(d.themes()); e.setStakes(d.stakes()); @@ -263,7 +235,7 @@ public class ImportService { e.setName(d.name()); e.setDescription(d.description()); e.setIcon(d.icon()); - e.setCampaignId(remapRequired(campaignMap, d.campaignId())); + e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); e.setOrder(d.order()); List items = new ArrayList<>(); for (ContentExport.CatalogItemDto i : nullSafe(d.items())) { @@ -292,7 +264,7 @@ public class ImportService { e.setDescription(d.description()); e.setDiceFormula(d.diceFormula()); e.setIcon(d.icon()); - e.setCampaignId(remapRequired(campaignMap, d.campaignId())); + e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); e.setOrder(d.order()); List entries = new ArrayList<>(); for (ContentExport.RandomTableEntryDto en : nullSafe(d.entries())) { @@ -318,7 +290,7 @@ public class ImportService { ChapterJpaEntity e = new ChapterJpaEntity(); e.setName(d.name()); e.setDescription(d.description()); - e.setArcId(remapRequired(arcMap, d.arcId())); + e.setArcId(IdRemapper.remapId(arcMap, d.arcId())); e.setOrder(d.order()); e.setPrerequisites(PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson())); // remappe plus bas e.setIcon(d.icon()); @@ -341,7 +313,7 @@ public class ImportService { e.setValues(d.values()); e.setImageValues(d.imageValues()); e.setKeyValueValues(d.keyValueValues()); - e.setCampaignId(remapRequired(campaignMap, d.campaignId())); + e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas e.setFolder(d.folder()); e.setOrder(d.order()); @@ -360,7 +332,7 @@ public class ImportService { e.setValues(d.values()); e.setImageValues(d.imageValues()); e.setKeyValueValues(d.keyValueValues()); - e.setCampaignId(remapRequired(campaignMap, d.campaignId())); + e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); e.setOrder(d.order()); enemyMap.put(d.id(), enemyRepo.save(e).getId()); } @@ -375,7 +347,7 @@ public class ImportService { e.setValues(d.values()); e.setImageValues(d.imageValues()); e.setKeyValueValues(d.keyValueValues()); - e.setCampaignId(remapNullable(campaignMap, d.campaignId())); + e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); e.setPlaythroughId(null); // Playthrough hors perimetre d'export e.setOrder(d.order()); characterMap.put(d.id(), characterRepo.save(e).getId()); @@ -387,7 +359,7 @@ public class ImportService { SceneJpaEntity e = new SceneJpaEntity(); e.setName(d.name()); e.setDescription(d.description()); - e.setChapterId(remapRequired(chapterMap, d.chapterId())); + e.setChapterId(IdRemapper.remapId(chapterMap, d.chapterId())); e.setOrder(d.order()); e.setIcon(d.icon()); e.setLocation(d.location()); @@ -434,8 +406,8 @@ public class ImportService { // Campaign.loreId & gameSystemId (refs faibles String -> remap via maps Long). for (Long newCampaignId : campaignMap.values()) { campaignRepo.findById(newCampaignId).ifPresent(c -> { - String newLore = remapStringId(loreMap, c.getLoreId()); - String newGs = remapStringId(gameSystemMap, c.getGameSystemId()); + String newLore = IdRemapper.remapStringId(loreMap, c.getLoreId()); + String newGs = IdRemapper.remapStringId(gameSystemMap, c.getGameSystemId()); c.setLoreId(newLore); c.setGameSystemId(newGs); campaignRepo.save(c); @@ -445,7 +417,7 @@ public class ImportService { // Page.relatedPageIds for (Long newPageId : pageMap.values()) { pageRepo.findById(newPageId).ifPresent(p -> { - p.setRelatedPageIds(remapStringList(pageMap, p.getRelatedPageIds())); + p.setRelatedPageIds(IdRemapper.remapStringList(pageMap, p.getRelatedPageIds())); pageRepo.save(p); }); } @@ -453,7 +425,7 @@ public class ImportService { // Arc.relatedPageIds for (Long newArcId : arcMap.values()) { arcRepo.findById(newArcId).ifPresent(a -> { - a.setRelatedPageIds(remapStringList(pageMap, a.getRelatedPageIds())); + a.setRelatedPageIds(IdRemapper.remapStringList(pageMap, a.getRelatedPageIds())); arcRepo.save(a); }); } @@ -461,8 +433,8 @@ public class ImportService { // Chapter.relatedPageIds + prerequisites(QuestCompleted -> map Chapter) for (Long newChapterId : chapterMap.values()) { chapterRepo.findById(newChapterId).ifPresent(c -> { - c.setRelatedPageIds(remapStringList(pageMap, c.getRelatedPageIds())); - c.setPrerequisites(remapPrerequisites(chapterMap, c.getPrerequisites())); + c.setRelatedPageIds(IdRemapper.remapStringList(pageMap, c.getRelatedPageIds())); + c.setPrerequisites(IdRemapper.remapPrerequisites(chapterMap, c.getPrerequisites())); chapterRepo.save(c); }); } @@ -470,7 +442,7 @@ public class ImportService { // Npc.relatedPageIds for (Long newNpcId : npcMap.values()) { npcRepo.findById(newNpcId).ifPresent(n -> { - n.setRelatedPageIds(remapStringList(pageMap, n.getRelatedPageIds())); + n.setRelatedPageIds(IdRemapper.remapStringList(pageMap, n.getRelatedPageIds())); npcRepo.save(n); }); } @@ -478,9 +450,9 @@ public class ImportService { // Scene.relatedPageIds + enemyIds(map Enemy) + branches.targetSceneId(map Scene) for (Long newSceneId : sceneMap.values()) { sceneRepo.findById(newSceneId).ifPresent(s -> { - s.setRelatedPageIds(remapStringList(pageMap, s.getRelatedPageIds())); - s.setEnemyIds(remapStringList(enemyMap, s.getEnemyIds())); - s.setBranches(remapBranches(sceneMap, s.getBranches())); + s.setRelatedPageIds(IdRemapper.remapStringList(pageMap, s.getRelatedPageIds())); + s.setEnemyIds(IdRemapper.remapStringList(enemyMap, s.getEnemyIds())); + s.setBranches(IdRemapper.remapBranches(sceneMap, s.getBranches())); sceneRepo.save(s); }); } @@ -488,104 +460,40 @@ public class ImportService { return result.build(); } - // ----- Images ----- + // ----- Lecture de l'archive ----- - private void importImages(ContentExport export, - Map imageBinaries, - ImportResult.Builder result) { - // Index des metadonnees d'image par cle (depuis le data.json). - Map metaByKey = new HashMap<>(); - for (ContentExport.ImageDto img : nullSafe(export.images())) { - if (img.storageKey() != null) metaByKey.put(img.storageKey(), img); - } + /** Contenu déballé d'un zip d'import : le {@code data.json} parsé + les binaires d'images. */ + private record ParsedArchive(ContentExport export, Map imageBinaries) { + } - for (Map.Entry bin : imageBinaries.entrySet()) { - String storageKey = bin.getKey(); - byte[] data = bin.getValue(); - if (imageRepo.findByStorageKey(storageKey).isPresent()) { - // Image deja presente : on reutilise, pas de reupload (eviter doublon). - result.imageReused(); - continue; + /** + * Déballe le zip : {@code data.json → ContentExport} et {@code images/ → binaire} + * (le {@code manifest.json} est ignoré, info seulement). Lève si {@code data.json} manque. + */ + private ParsedArchive parseArchive(InputStream zipStream) { + ContentExport export = null; + Map imageBinaries = new LinkedHashMap<>(); // storageKey -> binaire + try (ZipInputStream zip = new ZipInputStream(zipStream)) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + String name = entry.getName(); + if ("data.json".equals(name)) { + export = objectMapper.readValue(readAll(zip), ContentExport.class); + } else if (name.startsWith("images/") && !entry.isDirectory()) { + // La cle de stockage est le chemin sans le prefixe "images/" du zip, + // c'est-a-dire EXACTEMENT le storageKey d'origine ("images/UUID.ext"). + String storageKey = name.substring("images/".length()); + imageBinaries.put(storageKey, readAll(zip)); + } + zip.closeEntry(); } - ContentExport.ImageDto meta = metaByKey.get(storageKey); - String contentType = meta != null && meta.contentType() != null - ? meta.contentType() : guessContentType(storageKey); - long size = meta != null ? meta.sizeBytes() : data.length; - - imageStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length); - - ImageJpaEntity e = new ImageJpaEntity(); - e.setFilename(meta != null && meta.filename() != null - ? meta.filename() : fileNameOf(storageKey)); - e.setContentType(contentType); - e.setSizeBytes(size); - e.setStorageKey(storageKey); - imageRepo.save(e); - result.imageUploaded(); + } catch (IOException e) { + throw new UncheckedIOException("Echec de lecture du zip d'import", e); } - } - - // ----- Helpers de remapping ----- - - /** Remap obligatoire d'une FK Long : si absente de la map, on garde l'ancienne valeur. */ - private Long remapRequired(Map map, Long oldId) { - if (oldId == null) return null; - return map.getOrDefault(oldId, oldId); - } - - /** Remap d'une FK Long nullable : null reste null. */ - private Long remapNullable(Map map, Long oldId) { - if (oldId == null) return null; - return map.getOrDefault(oldId, oldId); - } - - /** Remap d'un id stocke en String ("oldLong" -> "newLong") via une map Long. */ - private String remapStringId(Map map, String oldId) { - if (oldId == null || oldId.isBlank()) return oldId; - try { - Long newId = map.get(Long.parseLong(oldId.trim())); - return newId != null ? String.valueOf(newId) : oldId; - } catch (NumberFormatException ex) { - return oldId; // pas un Long : on laisse tel quel - } - } - - private List remapStringList(Map map, List ids) { - if (ids == null) return null; - List out = new ArrayList<>(ids.size()); - for (String id : ids) out.add(remapStringId(map, id)); - return out; - } - - private List remapPrerequisites(Map chapterMap, List prereqs) { - if (prereqs == null) return null; - List out = new ArrayList<>(prereqs.size()); - for (Prerequisite p : prereqs) { - if (p instanceof Prerequisite.QuestCompleted qc) { - out.add(new Prerequisite.QuestCompleted(remapStringId(chapterMap, qc.questId()))); - } else { - out.add(p); // FlagSet / SessionReached : inchanges - } - } - return out; - } - - private List remapBranches(Map sceneMap, List branches) { - if (branches == null) return null; - List out = new ArrayList<>(branches.size()); - for (SceneBranch b : branches) { - out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition())); - } - return out; - } - - private com.loremind.domain.campaigncontext.ArcType parseArcType(String type) { - if (type == null) return com.loremind.domain.campaigncontext.ArcType.LINEAR; - try { - return com.loremind.domain.campaigncontext.ArcType.valueOf(type); - } catch (IllegalArgumentException ex) { - return com.loremind.domain.campaigncontext.ArcType.LINEAR; + if (export == null) { + throw new IllegalArgumentException("Archive invalide : data.json introuvable"); } + return new ParsedArchive(export, imageBinaries); } // ----- Helpers divers ----- @@ -599,18 +507,4 @@ public class ImportService { in.transferTo(buffer); return buffer.toByteArray(); } - - private static String fileNameOf(String storageKey) { - int slash = storageKey.lastIndexOf('/'); - return slash >= 0 ? storageKey.substring(slash + 1) : storageKey; - } - - private static String guessContentType(String storageKey) { - String lower = storageKey.toLowerCase(); - if (lower.endsWith(".png")) return "image/png"; - if (lower.endsWith(".gif")) return "image/gif"; - if (lower.endsWith(".webp")) return "image/webp"; - if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; - return "application/octet-stream"; - } } diff --git a/core/src/main/java/com/loremind/infrastructure/updates/SemverComparator.java b/core/src/main/java/com/loremind/infrastructure/updates/SemverComparator.java new file mode 100644 index 0000000..1c3baf8 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/updates/SemverComparator.java @@ -0,0 +1,81 @@ +package com.loremind.infrastructure.updates; + +import org.springframework.lang.Nullable; + +import java.util.List; + +/** + * Parsing et comparaison de versions semver (stratégie de {@link UpdateCheckService}). + *

+ * Volontairement tolérant : préfixe {@code v}/{@code V} accepté, pré-release + * ({@code -beta.1}) et build metadata ({@code +build.42}) strippés avant comparaison. + * Un tag non parsable est simplement ignoré (jamais d'exception). Fonctions PURES. + */ +final class SemverComparator { + + private SemverComparator() { + } + + /** + * Parcourt la liste des tags, garde uniquement ceux qui parsent en semver + * (1 à 3 chiffres séparés par des points, préfixe {@code v} optionnel), + * retourne le plus élevé, ou {@code null} si aucun n'est valide. + */ + @Nullable + static String findMaxSemver(List tags) { + String maxTag = null; + int[] maxParts = null; + for (String t : tags) { + if (t == null || t.isBlank()) continue; + int[] parts = parseSemver(t); + if (parts == null) continue; + if (maxParts == null || compareParts(parts, maxParts) > 0) { + maxParts = parts; + maxTag = t; + } + } + return maxTag; + } + + /** @return {@code [major, minor, patch]} ou {@code null} si non parsable. */ + @Nullable + static int[] parseSemver(String tag) { + if (tag == null) return null; + String s = tag.trim(); + if (s.isEmpty()) return null; + if (s.startsWith("v") || s.startsWith("V")) s = s.substring(1); + int dashIdx = s.indexOf('-'); + if (dashIdx > 0) s = s.substring(0, dashIdx); + int plusIdx = s.indexOf('+'); + if (plusIdx > 0) s = s.substring(0, plusIdx); + String[] parts = s.split("\\."); + if (parts.length < 1 || parts.length > 3) return null; + int[] result = new int[]{0, 0, 0}; + for (int i = 0; i < parts.length; i++) { + try { + int v = Integer.parseInt(parts[i]); + if (v < 0) return null; + result[i] = v; + } catch (NumberFormatException e) { + return null; + } + } + return result; + } + + /** Compare deux versions semver brutes (préfixe toléré). Négatif si {@code a < b}. */ + static int compareSemver(String a, String b) { + int[] aParts = parseSemver(a); + int[] bParts = parseSemver(b); + if (aParts == null || bParts == null) return 0; + return compareParts(aParts, bParts); + } + + private static int compareParts(int[] a, int[] b) { + for (int i = 0; i < 3; i++) { + int diff = Integer.compare(a[i], b[i]); + if (diff != 0) return diff; + } + return 0; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java b/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java index 2c53f94..bb81920 100644 --- a/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java +++ b/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java @@ -25,7 +25,6 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Base64; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -114,32 +113,9 @@ public class UpdateCheckService { return new UpdateStatus(true, false, true, null, statuses, Instant.now()); } - List statuses = new ArrayList<>(); - boolean anyUpdate = false; - boolean anyUnknown = false; - for (String image : images) { - String latest = null; - try { - latest = fetchLatestSemverTag(registry, image, null); - } catch (Exception e) { - log.warn("Tags fetch failed for {}: {}", image, e.getMessage()); - } - ImageStatusKind kind; - if (latest == null) { - kind = ImageStatusKind.UNKNOWN; - anyUnknown = true; - } else { - int cmp = compareSemver(currentVersion, latest); - if (cmp >= 0) { - kind = ImageStatusKind.UP_TO_DATE; - } else { - kind = ImageStatusKind.UPDATE_AVAILABLE; - anyUpdate = true; - } - } - statuses.add(new ImageStatus(image, currentVersion, latest, kind)); - } - return new UpdateStatus(true, anyUpdate, anyUnknown, currentVersion, statuses, Instant.now()); + ImagesEvaluation ev = evaluateImages(images, registry, null); + return new UpdateStatus(true, ev.anyUpdate(), ev.anyUnknown(), + currentVersion, ev.statuses(), Instant.now()); } /** @@ -169,21 +145,37 @@ public class UpdateCheckService { (creds.get().username() + ":" + creds.get().password()).getBytes(StandardCharsets.UTF_8)); String betaRegistry = normalizeRegistry(creds.get().registry()); + ImagesEvaluation ev = evaluateImages(betaImages, betaRegistry, basicAuth); + return new BetaStatus(true, ev.anyUpdate(), ev.anyUnknown(), + ev.statuses(), Instant.now(), null); + } + + /** Résultat de l'évaluation d'une liste d'images : statuts + drapeaux agrégés. */ + private record ImagesEvaluation(List statuses, boolean anyUpdate, boolean anyUnknown) { + } + + /** + * Boucle commune à {@link #check()} et {@link #checkBeta()} : pour chaque image, + * récupère le tag semver le plus haut et le compare à la version courante. + * Une image dont les tags sont injoignables ou non semver → {@code UNKNOWN}. + */ + private ImagesEvaluation evaluateImages(List imageList, String registryUrl, + @Nullable String authHeader) { List statuses = new ArrayList<>(); boolean anyUpdate = false; boolean anyUnknown = false; - for (String image : betaImages) { + for (String image : imageList) { String latest = null; try { - latest = fetchLatestSemverTag(betaRegistry, image, basicAuth); + latest = fetchLatestSemverTag(registryUrl, image, authHeader); } catch (Exception e) { - log.warn("Beta tags fetch failed for {}: {}", image, e.getMessage()); + log.warn("Tags fetch failed for {}: {}", image, e.getMessage()); } ImageStatusKind kind; if (latest == null) { kind = ImageStatusKind.UNKNOWN; anyUnknown = true; - } else if (currentVersion != null && compareSemver(currentVersion, latest) >= 0) { + } else if (currentVersion != null && SemverComparator.compareSemver(currentVersion, latest) >= 0) { kind = ImageStatusKind.UP_TO_DATE; } else { kind = ImageStatusKind.UPDATE_AVAILABLE; @@ -191,7 +183,7 @@ public class UpdateCheckService { } statuses.add(new ImageStatus(image, currentVersion, latest, kind)); } - return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null); + return new ImagesEvaluation(statuses, anyUpdate, anyUnknown); } public void apply() { @@ -209,6 +201,10 @@ public class UpdateCheckService { // ----------------------------------------------------------------------- // Registry HTTP API v2 - tags listing + auth bearer + // + // NB : le RestTemplate (champ `http`) reste porté par ce service — les tests + // l'injectent par réflexion. Le parsing semver et le parsing du challenge + // WWW-Authenticate sont, eux, externalisés (SemverComparator, WwwAuthenticate). // ----------------------------------------------------------------------- /** @@ -244,7 +240,7 @@ public class UpdateCheckService { body = tagsCall(url, bearerHeaders); } if (body == null || body.tags == null || body.tags.isEmpty()) return null; - return findMaxSemver(body.tags); + return SemverComparator.findMaxSemver(body.tags); } private TagsListResponse tagsCall(String url, HttpHeaders headers) { @@ -253,69 +249,6 @@ public class UpdateCheckService { return resp.getBody(); } - /** - * Parcourt la liste des tags, garde uniquement ceux qui parsent en semver - * (1 a 3 chiffres separes par des points, optionnel prefix "v"), retourne le max. - * Pre-release / build metadata sont strippes pour la comparaison. - */ - @Nullable - static String findMaxSemver(List tags) { - String maxTag = null; - int[] maxParts = null; - for (String t : tags) { - if (t == null || t.isBlank()) continue; - int[] parts = parseSemver(t); - if (parts == null) continue; - if (maxParts == null || compareParts(parts, maxParts) > 0) { - maxParts = parts; - maxTag = t; - } - } - return maxTag; - } - - /** @return [major, minor, patch] ou null si non parsable. */ - @Nullable - static int[] parseSemver(String tag) { - if (tag == null) return null; - String s = tag.trim(); - if (s.isEmpty()) return null; - if (s.startsWith("v") || s.startsWith("V")) s = s.substring(1); - int dashIdx = s.indexOf('-'); - if (dashIdx > 0) s = s.substring(0, dashIdx); - int plusIdx = s.indexOf('+'); - if (plusIdx > 0) s = s.substring(0, plusIdx); - String[] parts = s.split("\\."); - if (parts.length < 1 || parts.length > 3) return null; - int[] result = new int[]{0, 0, 0}; - for (int i = 0; i < parts.length; i++) { - try { - int v = Integer.parseInt(parts[i]); - if (v < 0) return null; - result[i] = v; - } catch (NumberFormatException e) { - return null; - } - } - return result; - } - - /** Compare deux versions semver brutes (sans prefix). Negatif si a < b. */ - static int compareSemver(String a, String b) { - int[] aParts = parseSemver(a); - int[] bParts = parseSemver(b); - if (aParts == null || bParts == null) return 0; - return compareParts(aParts, bParts); - } - - private static int compareParts(int[] a, int[] b) { - for (int i = 0; i < 3; i++) { - int diff = Integer.compare(a[i], b[i]); - if (diff != 0) return diff; - } - return 0; - } - /** * Suit le challenge {@code WWW-Authenticate: Bearer realm="..."} pour obtenir * un token. Si {@code basicAuth} est fourni, l'utilise pour l'echange (cas @@ -326,7 +259,7 @@ public class UpdateCheckService { if (wwwAuth == null) return null; String prefix = "Bearer "; if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null; - Map params = parseAuthParams(wwwAuth.substring(prefix.length())); + Map params = WwwAuthenticate.parseParams(wwwAuth.substring(prefix.length())); String realm = params.get("realm"); if (realm == null) return null; StringBuilder url = new StringBuilder(realm); @@ -359,34 +292,6 @@ public class UpdateCheckService { } } - /** Parser minimaliste pour {@code key="value", key2="value2"}. */ - private static Map parseAuthParams(String s) { - Map out = new HashMap<>(); - int i = 0; - int n = s.length(); - while (i < n) { - while (i < n && (s.charAt(i) == ',' || s.charAt(i) == ' ')) i++; - int eq = s.indexOf('=', i); - if (eq < 0) break; - String key = s.substring(i, eq).trim(); - int valStart = eq + 1; - String val; - if (valStart < n && s.charAt(valStart) == '"') { - int valEnd = s.indexOf('"', valStart + 1); - if (valEnd < 0) break; - val = s.substring(valStart + 1, valEnd); - i = valEnd + 1; - } else { - int valEnd = s.indexOf(',', valStart); - if (valEnd < 0) valEnd = n; - val = s.substring(valStart, valEnd).trim(); - i = valEnd; - } - out.put(key, val); - } - return out; - } - private static String normalizeRegistry(String value) { if (value == null || value.isBlank()) return ""; String v = value.trim(); diff --git a/core/src/main/java/com/loremind/infrastructure/updates/WwwAuthenticate.java b/core/src/main/java/com/loremind/infrastructure/updates/WwwAuthenticate.java new file mode 100644 index 0000000..742a374 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/updates/WwwAuthenticate.java @@ -0,0 +1,46 @@ +package com.loremind.infrastructure.updates; + +import java.util.HashMap; +import java.util.Map; + +/** + * Parseur minimaliste des paramètres d'un challenge HTTP + * {@code WWW-Authenticate: Bearer realm="...", service="...", scope="..."} + * (cf. le flux de token du registry Docker dans {@link UpdateCheckService}). + *

+ * Accepte les valeurs entre guillemets ({@code key="value"}) comme non quotées + * ({@code key=value}), séparées par des virgules et/ou espaces. Fonction PURE. + */ +final class WwwAuthenticate { + + private WwwAuthenticate() { + } + + /** Parse {@code key="value", key2=value2} en map clé→valeur (sans les guillemets). */ + static Map parseParams(String s) { + Map out = new HashMap<>(); + int i = 0; + int n = s.length(); + while (i < n) { + while (i < n && (s.charAt(i) == ',' || s.charAt(i) == ' ')) i++; + int eq = s.indexOf('=', i); + if (eq < 0) break; + String key = s.substring(i, eq).trim(); + int valStart = eq + 1; + String val; + if (valStart < n && s.charAt(valStart) == '"') { + int valEnd = s.indexOf('"', valStart + 1); + if (valEnd < 0) break; + val = s.substring(valStart + 1, valEnd); + i = valEnd + 1; + } else { + int valEnd = s.indexOf(',', valStart); + if (valEnd < 0) valEnd = n; + val = s.substring(valStart, valEnd).trim(); + i = valEnd; + } + out.put(key, val); + } + return out; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/AiChatController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/AiChatController.java index 6247c1f..a73ef25 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/AiChatController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/AiChatController.java @@ -9,6 +9,7 @@ import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO; import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamCampaignRequestDTO; import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO; import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamSessionRequestDTO; +import com.loremind.infrastructure.web.sse.SseJson; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.core.task.TaskExecutor; import org.springframework.http.MediaType; @@ -162,7 +163,7 @@ public class AiChatController { private void sendToken(SseEmitter emitter, String token) { try { emitter.send(SseEmitter.event() - .data("{\"token\":" + jsonEscape(token) + "}")); + .data("{\"token\":" + SseJson.escape(token) + "}")); } catch (IOException e) { emitter.completeWithError(e); } @@ -182,7 +183,7 @@ public class AiChatController { String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName(); emitter.send(SseEmitter.event() .name("error") - .data("{\"message\":" + jsonEscape(message) + "}")); + .data("{\"message\":" + SseJson.escape(message) + "}")); emitter.complete(); } catch (IOException ioe) { emitter.completeWithError(ioe); @@ -191,31 +192,6 @@ public class AiChatController { // --- Utilitaires -------------------------------------------------------- - /** Encadre une chaîne de guillemets et échappe les caractères JSON dangereux. */ - private String jsonEscape(String raw) { - if (raw == null) return "\"\""; - StringBuilder sb = new StringBuilder(raw.length() + 2); - sb.append('"'); - for (int i = 0; i < raw.length(); i++) { - char c = raw.charAt(i); - switch (c) { - case '"': sb.append("\\\""); break; - case '\\': sb.append("\\\\"); break; - case '\n': sb.append("\\n"); break; - case '\r': sb.append("\\r"); break; - case '\t': sb.append("\\t"); break; - default: - if (c < 0x20) { - sb.append(String.format("\\u%04x", (int) c)); - } else { - sb.append(c); - } - } - } - sb.append('"'); - return sb.toString(); - } - private List toDomainMessages(List dtos) { if (dtos == null) return List.of(); return dtos.stream() diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java index c30d5fd..855468d 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java @@ -5,10 +5,8 @@ import com.loremind.application.gamesystemcontext.GameSystemService; import com.loremind.domain.gamesystemcontext.GameSystem; import com.loremind.domain.gamesystemcontext.RulesImportResult; import com.loremind.domain.gamesystemcontext.ports.RulesImportException; -import com.loremind.domain.shared.template.TemplateField; import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO; import com.loremind.infrastructure.web.dto.gamesystemcontext.RulesImportResponseDTO; -import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO; import com.loremind.infrastructure.web.mapper.GameSystemMapper; import com.loremind.infrastructure.web.mapper.TemplateFieldMapper; import org.slf4j.Logger; @@ -23,7 +21,6 @@ import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; @@ -261,18 +258,11 @@ public class GameSystemController { dto.getName(), dto.getDescription(), dto.getRulesMarkdown(), - toDomainFields(dto.getCharacterTemplate()), - toDomainFields(dto.getNpcTemplate()), - toDomainFields(dto.getEnemyTemplate()), + templateFieldMapper.toDomainList(dto.getCharacterTemplate()), + templateFieldMapper.toDomainList(dto.getNpcTemplate()), + templateFieldMapper.toDomainList(dto.getEnemyTemplate()), dto.getAuthor(), dto.isPublic() ); } - - private List toDomainFields(List dtos) { - if (dtos == null) return new ArrayList<>(); - List out = new ArrayList<>(dtos.size()); - for (TemplateFieldDTO d : dtos) out.add(templateFieldMapper.toDomain(d)); - return out; - } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java index ea56a63..a71aa98 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java @@ -5,6 +5,7 @@ import com.loremind.domain.campaigncontext.Notebook; import com.loremind.domain.campaigncontext.NotebookSource; import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer; import com.loremind.domain.campaigncontext.ports.NotebookException; +import com.loremind.infrastructure.web.sse.SseJson; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.core.task.TaskExecutor; import org.springframework.http.HttpStatus; @@ -211,7 +212,7 @@ public class NotebookController { private void sendToken(SseEmitter emitter, String token) { try { - emitter.send(SseEmitter.event().name("token").data("{\"token\":" + jsonEscape(token) + "}")); + emitter.send(SseEmitter.event().name("token").data("{\"token\":" + SseJson.escape(token) + "}")); } catch (IOException | IllegalStateException e) { // flux fermé/expiré : on cesse d'écrire } @@ -247,32 +248,13 @@ public class NotebookController { private void fail(SseEmitter emitter, Throwable error) { try { String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName(); - emitter.send(SseEmitter.event().name("error").data("{\"message\":" + jsonEscape(message) + "}")); + emitter.send(SseEmitter.event().name("error").data("{\"message\":" + SseJson.escape(message) + "}")); emitter.complete(); } catch (IOException | IllegalStateException e) { // flux déjà fermé/expiré : rien à envoyer } } - private String jsonEscape(String raw) { - if (raw == null) return "\"\""; - StringBuilder sb = new StringBuilder(raw.length() + 2).append('"'); - for (int i = 0; i < raw.length(); i++) { - char c = raw.charAt(i); - switch (c) { - case '"': sb.append("\\\""); break; - case '\\': sb.append("\\\\"); break; - case '\n': sb.append("\\n"); break; - case '\r': sb.append("\\r"); break; - case '\t': sb.append("\\t"); break; - default: - if (c < 0x20) sb.append(String.format("\\u%04x", (int) c)); - else sb.append(c); - } - } - return sb.append('"').toString(); - } - public record CreateRequest(String campaignId, String name) {} public record RenameRequest(String name) {} /** diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java index eb19bc8..c611dc0 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java @@ -1,14 +1,9 @@ package com.loremind.infrastructure.web.mapper; import com.loremind.domain.gamesystemcontext.GameSystem; -import com.loremind.domain.shared.template.TemplateField; import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO; -import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO; import org.springframework.stereotype.Component; -import java.util.ArrayList; -import java.util.List; - @Component public class GameSystemMapper { @@ -25,9 +20,9 @@ public class GameSystemMapper { dto.setName(g.getName()); dto.setDescription(g.getDescription()); dto.setRulesMarkdown(g.getRulesMarkdown()); - dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate())); - dto.setNpcTemplate(toDTOList(g.getNpcTemplate())); - dto.setEnemyTemplate(toDTOList(g.getEnemyTemplate())); + dto.setCharacterTemplate(fieldMapper.toDTOList(g.getCharacterTemplate())); + dto.setNpcTemplate(fieldMapper.toDTOList(g.getNpcTemplate())); + dto.setEnemyTemplate(fieldMapper.toDTOList(g.getEnemyTemplate())); dto.setAuthor(g.getAuthor()); dto.setPublic(g.isPublic()); return dto; @@ -40,25 +35,11 @@ public class GameSystemMapper { .name(dto.getName()) .description(dto.getDescription()) .rulesMarkdown(dto.getRulesMarkdown()) - .characterTemplate(toDomainList(dto.getCharacterTemplate())) - .npcTemplate(toDomainList(dto.getNpcTemplate())) - .enemyTemplate(toDomainList(dto.getEnemyTemplate())) + .characterTemplate(fieldMapper.toDomainList(dto.getCharacterTemplate())) + .npcTemplate(fieldMapper.toDomainList(dto.getNpcTemplate())) + .enemyTemplate(fieldMapper.toDomainList(dto.getEnemyTemplate())) .author(dto.getAuthor()) .isPublic(dto.isPublic()) .build(); } - - private List toDTOList(List fields) { - if (fields == null) return new ArrayList<>(); - List out = new ArrayList<>(fields.size()); - for (TemplateField f : fields) out.add(fieldMapper.toDTO(f)); - return out; - } - - private List toDomainList(List dtos) { - if (dtos == null) return new ArrayList<>(); - List out = new ArrayList<>(dtos.size()); - for (TemplateFieldDTO d : dtos) out.add(fieldMapper.toDomain(d)); - return out; - } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java index 0807726..0fe11e2 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java @@ -60,4 +60,20 @@ public class TemplateFieldMapper { } return new TemplateField(dto.getName(), type, layout, labels); } + + /** Mappe une liste de champs domaine → DTO ({@code null} → liste vide). */ + public List toDTOList(List fields) { + if (fields == null) return new ArrayList<>(); + List out = new ArrayList<>(fields.size()); + for (TemplateField f : fields) out.add(toDTO(f)); + return out; + } + + /** Mappe une liste de champs DTO → domaine ({@code null} → liste vide). */ + public List toDomainList(List dtos) { + if (dtos == null) return new ArrayList<>(); + List out = new ArrayList<>(dtos.size()); + for (TemplateFieldDTO d : dtos) out.add(toDomain(d)); + return out; + } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/sse/SseJson.java b/core/src/main/java/com/loremind/infrastructure/web/sse/SseJson.java new file mode 100644 index 0000000..7dbbfcc --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/sse/SseJson.java @@ -0,0 +1,45 @@ +package com.loremind.infrastructure.web.sse; + +/** + * Petits utilitaires d'écriture JSON pour les payloads SSE bâtis à la main par + * les controllers de streaming (chat IA, notebook…). + *

+ * Volontairement minimaliste (pas de Jackson) : les payloads concernés sont des + * objets plats à un ou deux champs ({@code {"token":"…"}}, {@code {"message":"…"}}) + * écrits dans la boucle de streaming, où l'on veut éviter l'allocation d'un + * ObjectMapper par token. + */ +public final class SseJson { + + private SseJson() { + } + + /** + * Encadre {@code raw} de guillemets et échappe les caractères JSON dangereux + * (guillemet, antislash, retours chariot, tabulation, caractères de contrôle). + * Renvoie {@code "\"\""} (chaîne JSON vide) si {@code raw} est {@code null}. + */ + public static String escape(String raw) { + if (raw == null) return "\"\""; + StringBuilder sb = new StringBuilder(raw.length() + 2); + sb.append('"'); + for (int i = 0; i < raw.length(); i++) { + char c = raw.charAt(i); + switch (c) { + case '"': sb.append("\\\""); break; + case '\\': sb.append("\\\\"); break; + case '\n': sb.append("\\n"); break; + case '\r': sb.append("\\r"); break; + case '\t': sb.append("\\t"); break; + default: + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + return sb.toString(); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/IdRemapperTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/IdRemapperTest.java new file mode 100644 index 0000000..3a3aced --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/transfer/IdRemapperTest.java @@ -0,0 +1,143 @@ +package com.loremind.infrastructure.transfer; + +import com.loremind.domain.campaigncontext.ArcType; +import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.domain.campaigncontext.SceneBranch; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Tests unitaires PURS (sans Spring/DB) de {@link IdRemapper}, la logique de + * remapping {@code oldId → newId} de l'import en mode FUSION. Verrouille les + * invariants critiques : une référence absente de la map est CONSERVÉE, jamais + * perdue ni remplacée par null. + */ +class IdRemapperTest { + + private static final Map MAP = Map.of(1L, 100L, 2L, 200L); + + // --- remapId (FK Long) --------------------------------------------------- + + @Test + void remapId_null_resteNull() { + assertNull(IdRemapper.remapId(MAP, null)); + } + + @Test + void remapId_present_estRemappe() { + assertEquals(100L, IdRemapper.remapId(MAP, 1L)); + } + + @Test + void remapId_absent_conserveLAncienneValeur() { + assertEquals(42L, IdRemapper.remapId(MAP, 42L)); + } + + // --- remapStringId (ref faible String) ----------------------------------- + + @Test + void remapStringId_null_resteNull() { + assertNull(IdRemapper.remapStringId(MAP, null)); + } + + @Test + void remapStringId_blanc_resteInchange() { + assertEquals(" ", IdRemapper.remapStringId(MAP, " ")); + } + + @Test + void remapStringId_numeriquePresent_estRemappe() { + assertEquals("200", IdRemapper.remapStringId(MAP, "2")); + } + + @Test + void remapStringId_numeriqueAvecEspaces_estTrimmeEtRemappe() { + assertEquals("100", IdRemapper.remapStringId(MAP, " 1 ")); + } + + @Test + void remapStringId_numeriqueAbsent_conserveLAncien() { + assertEquals("999", IdRemapper.remapStringId(MAP, "999")); + } + + @Test + void remapStringId_nonNumerique_resteInchange() { + assertEquals("abc-uuid", IdRemapper.remapStringId(MAP, "abc-uuid")); + } + + // --- remapStringList ----------------------------------------------------- + + @Test + void remapStringList_null_resteNull() { + assertNull(IdRemapper.remapStringList(MAP, null)); + } + + @Test + void remapStringList_remappeChaqueElement() { + assertEquals(List.of("100", "999", "200"), + IdRemapper.remapStringList(MAP, List.of("1", "999", "2"))); + } + + // --- remapPrerequisites -------------------------------------------------- + + @Test + void remapPrerequisites_null_resteNull() { + assertNull(IdRemapper.remapPrerequisites(MAP, null)); + } + + @Test + void remapPrerequisites_questCompleted_remappeLeQuestId() { + List out = IdRemapper.remapPrerequisites( + MAP, List.of(new Prerequisite.QuestCompleted("1"))); + assertEquals("100", ((Prerequisite.QuestCompleted) out.get(0)).questId()); + } + + @Test + void remapPrerequisites_autresTypes_inchanges() { + Prerequisite flag = new Prerequisite.FlagSet("porte_ouverte"); + Prerequisite session = new Prerequisite.SessionReached(3); + List out = IdRemapper.remapPrerequisites(MAP, List.of(flag, session)); + // FlagSet / SessionReached ne sont pas réécrits : même instance. + assertSame(flag, out.get(0)); + assertSame(session, out.get(1)); + } + + // --- remapBranches ------------------------------------------------------- + + @Test + void remapBranches_null_resteNull() { + assertNull(IdRemapper.remapBranches(MAP, null)); + } + + @Test + void remapBranches_remappeTargetSceneId_etPreserveLabelEtCondition() { + SceneBranch in = new SceneBranch("Si attaque", "2", "notes MJ"); + SceneBranch out = IdRemapper.remapBranches(MAP, List.of(in)).get(0); + assertEquals("Si attaque", out.label()); + assertEquals("200", out.targetSceneId()); + assertEquals("notes MJ", out.condition()); + } + + // --- parseArcType -------------------------------------------------------- + + @Test + void parseArcType_null_donneLinear() { + assertEquals(ArcType.LINEAR, IdRemapper.parseArcType(null)); + } + + @Test + void parseArcType_valeurValide_estParsee() { + assertEquals(ArcType.HUB, IdRemapper.parseArcType("HUB")); + } + + @Test + void parseArcType_valeurInconnue_retombeSurLinear() { + assertEquals(ArcType.LINEAR, IdRemapper.parseArcType("INEXISTANT")); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/updates/SemverComparatorTest.java b/core/src/test/java/com/loremind/infrastructure/updates/SemverComparatorTest.java new file mode 100644 index 0000000..202123e --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/updates/SemverComparatorTest.java @@ -0,0 +1,62 @@ +package com.loremind.infrastructure.updates; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests des utilitaires semver (extraits de UpdateCheckServiceTest lors de la + * séparation de {@link SemverComparator}). + */ +class SemverComparatorTest { + + @Test + void parseSemver_acceptsCommonFormats() { + assertArrayEquals(new int[]{0, 8, 0}, SemverComparator.parseSemver("0.8.0")); + assertArrayEquals(new int[]{0, 8, 0}, SemverComparator.parseSemver("v0.8.0")); + assertArrayEquals(new int[]{1, 0, 0}, SemverComparator.parseSemver("1.0.0")); + assertArrayEquals(new int[]{0, 8, 0}, SemverComparator.parseSemver("0.8.0-beta.1")); + assertArrayEquals(new int[]{0, 8, 0}, SemverComparator.parseSemver("0.8.0+build.42")); + } + + @Test + void parseSemver_rejectsInvalid() { + assertNull(SemverComparator.parseSemver(null)); + assertNull(SemverComparator.parseSemver("")); + assertNull(SemverComparator.parseSemver("latest")); + assertNull(SemverComparator.parseSemver("stable")); + assertNull(SemverComparator.parseSemver("0.8.0.1.2")); + assertNull(SemverComparator.parseSemver("0.x.0")); + } + + @Test + void compareSemver_basic() { + assertTrue(SemverComparator.compareSemver("0.7.2", "0.8.0") < 0); + assertTrue(SemverComparator.compareSemver("0.8.0", "0.7.2") > 0); + assertEquals(0, SemverComparator.compareSemver("0.8.0", "0.8.0")); + assertEquals(0, SemverComparator.compareSemver("v0.8.0", "0.8.0")); + assertTrue(SemverComparator.compareSemver("0.8.0", "0.10.0") < 0); + assertTrue(SemverComparator.compareSemver("1.0.0", "0.99.99") > 0); + } + + @Test + void findMaxSemver_picksHighest() { + assertEquals("0.8.0", SemverComparator.findMaxSemver( + List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest"))); + assertEquals("0.10.0", SemverComparator.findMaxSemver( + List.of("0.8.0", "0.10.0", "0.9.5"))); + assertEquals("v1.0.0", SemverComparator.findMaxSemver( + List.of("v0.8.0", "v1.0.0", "latest"))); + } + + @Test + void findMaxSemver_returnsNullWhenNoValidTag() { + assertNull(SemverComparator.findMaxSemver(List.of("latest", "stable", "main"))); + assertNull(SemverComparator.findMaxSemver(List.of())); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/updates/UpdateCheckServiceTest.java b/core/src/test/java/com/loremind/infrastructure/updates/UpdateCheckServiceTest.java index f1f94f2..42acf55 100644 --- a/core/src/test/java/com/loremind/infrastructure/updates/UpdateCheckServiceTest.java +++ b/core/src/test/java/com/loremind/infrastructure/updates/UpdateCheckServiceTest.java @@ -1,6 +1,5 @@ package com.loremind.infrastructure.updates; -import com.loremind.application.licensing.LicenseService; import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus; import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind; import com.loremind.infrastructure.updates.UpdateCheckService.TagsListResponse; @@ -194,52 +193,6 @@ public class UpdateCheckServiceTest { } } - // ----------------------------------------------------------------- - // Utilitaires semver - // ----------------------------------------------------------------- - - @Test - void parseSemver_acceptsCommonFormats() { - assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0")); - assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("v0.8.0")); - assertArrayEquals(new int[]{1, 0, 0}, UpdateCheckService.parseSemver("1.0.0")); - assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0-beta.1")); - assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0+build.42")); - } - - @Test - void parseSemver_rejectsInvalid() { - assertNull(UpdateCheckService.parseSemver(null)); - assertNull(UpdateCheckService.parseSemver("")); - assertNull(UpdateCheckService.parseSemver("latest")); - assertNull(UpdateCheckService.parseSemver("stable")); - assertNull(UpdateCheckService.parseSemver("0.8.0.1.2")); - assertNull(UpdateCheckService.parseSemver("0.x.0")); - } - - @Test - void compareSemver_basic() { - assertTrue(UpdateCheckService.compareSemver("0.7.2", "0.8.0") < 0); - assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.7.2") > 0); - assertEquals(0, UpdateCheckService.compareSemver("0.8.0", "0.8.0")); - assertEquals(0, UpdateCheckService.compareSemver("v0.8.0", "0.8.0")); - assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.10.0") < 0); - assertTrue(UpdateCheckService.compareSemver("1.0.0", "0.99.99") > 0); - } - - @Test - void findMaxSemver_picksHighest() { - assertEquals("0.8.0", UpdateCheckService.findMaxSemver( - List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest"))); - assertEquals("0.10.0", UpdateCheckService.findMaxSemver( - List.of("0.8.0", "0.10.0", "0.9.5"))); - assertEquals("v1.0.0", UpdateCheckService.findMaxSemver( - List.of("v0.8.0", "v1.0.0", "latest"))); - } - - @Test - void findMaxSemver_returnsNullWhenNoValidTag() { - assertNull(UpdateCheckService.findMaxSemver(List.of("latest", "stable", "main"))); - assertNull(UpdateCheckService.findMaxSemver(List.of())); - } + // NB : les utilitaires semver (parseSemver / findMaxSemver / compareSemver) sont + // désormais dans SemverComparator et testés par SemverComparatorTest. } diff --git a/core/src/test/java/com/loremind/infrastructure/updates/WwwAuthenticateTest.java b/core/src/test/java/com/loremind/infrastructure/updates/WwwAuthenticateTest.java new file mode 100644 index 0000000..67f1c02 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/updates/WwwAuthenticateTest.java @@ -0,0 +1,45 @@ +package com.loremind.infrastructure.updates; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests du parseur de challenge {@code WWW-Authenticate} ({@link WwwAuthenticate}), + * jusque-là non couvert quand il était privé dans UpdateCheckService. + */ +class WwwAuthenticateTest { + + @Test + void parse_challengeGhcrComplet() { + // Forme typique renvoyée par ghcr.io / Docker Hub. + Map p = WwwAuthenticate.parseParams( + "realm=\"https://ghcr.io/token\",service=\"ghcr.io\",scope=\"repository:org/img:pull\""); + assertEquals("https://ghcr.io/token", p.get("realm")); + assertEquals("ghcr.io", p.get("service")); + assertEquals("repository:org/img:pull", p.get("scope")); + } + + @Test + void parse_valeursNonQuotees() { + Map p = WwwAuthenticate.parseParams("realm=https://r/token, service=reg"); + assertEquals("https://r/token", p.get("realm")); + assertEquals("reg", p.get("service")); + } + + @Test + void parse_chaineVide_donneMapVide() { + assertTrue(WwwAuthenticate.parseParams("").isEmpty()); + } + + @Test + void parse_guillemetNonFerme_sArreteProprement() { + // Valeur ouverte sans guillemet fermant : on s'arrête sans planter. + Map p = WwwAuthenticate.parseParams("realm=\"https://r/token"); + assertNull(p.get("realm")); + } +} diff --git a/web/package-lock.json b/web/package-lock.json index a250aaf..e08a3eb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "loremind-web", - "version": "0.16.1", + "version": "0.16.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loremind-web", - "version": "0.16.1", + "version": "0.16.2", "dependencies": { "@angular/animations": "^21.2.16", "@angular/common": "^21.2.16", @@ -32,7 +32,9 @@ "@emnapi/core": "^1.11.0", "@emnapi/runtime": "^1.11.0", "@playwright/test": "^1.59.1", - "typescript": "~5.9.3" + "@vitest/coverage-v8": "^4.1.9", + "typescript": "~5.9.3", + "vitest": "^4.1.9" } }, "node_modules/@algolia/abtesting": { @@ -2518,6 +2520,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", @@ -5988,6 +6000,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -6009,6 +6032,13 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -6101,7 +6131,6 @@ "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -6210,6 +6239,158 @@ "vite": "^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -6678,6 +6859,35 @@ "node": ">=12.0.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", @@ -7099,6 +7309,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -8136,6 +8356,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -8196,6 +8426,16 @@ "node": ">=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -8747,6 +8987,13 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -9287,6 +9534,64 @@ "node": ">=10" } }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -9308,7 +9613,6 @@ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -9804,6 +10108,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, "node_modules/make-dir": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", @@ -10612,6 +10928,20 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -11009,6 +11339,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -11798,7 +12135,6 @@ "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -12281,6 +12617,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -12527,6 +12870,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -12537,6 +12887,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", @@ -12669,7 +13026,6 @@ "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -12768,6 +13124,23 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -12785,6 +13158,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -13173,6 +13556,97 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/watchpack": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", @@ -13892,6 +14366,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wildcard": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", diff --git a/web/package.json b/web/package.json index 4a548dd..36aaa41 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.16.1", + "version": "0.16.2", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", @@ -8,6 +8,9 @@ "build": "ng build", "watch": "ng build --watch --configuration development", "test": "ng test", + "test:unit": "vitest run", + "test:unit:watch": "vitest", + "test:unit:coverage": "vitest run --coverage", "e2e": "playwright test", "e2e:ui": "playwright test --ui", "e2e:headed": "playwright test --headed", @@ -39,6 +42,8 @@ "@emnapi/core": "^1.11.0", "@emnapi/runtime": "^1.11.0", "@playwright/test": "^1.59.1", - "typescript": "~5.9.3" + "@vitest/coverage-v8": "^4.1.9", + "typescript": "~5.9.3", + "vitest": "^4.1.9" } } diff --git a/web/src/app/campaigns/campaign-tree.helper.spec.ts b/web/src/app/campaigns/campaign-tree.helper.spec.ts new file mode 100644 index 0000000..9b47fd2 --- /dev/null +++ b/web/src/app/campaigns/campaign-tree.helper.spec.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { buildCampaignTree, CampaignTreeData } from './campaign-tree.helper'; +import { TranslateService } from '@ngx-translate/core'; + +// TranslateService factice : renvoie la clé telle quelle (suffisant pour vérifier +// la structure de l'arbre sans dépendre des traductions). +const t = { instant: (k: string) => k } as unknown as TranslateService; + +function data(partial: Partial = {}): CampaignTreeData { + return { + arcs: [], chaptersByArc: {}, scenesByChapter: {}, + characters: [], npcs: [], randomTables: [], enemies: [], + ...partial, + }; +} + +describe('buildCampaignTree', () => { + it('produit les nœuds fixes (PNJ, ennemis, tables, ateliers, catalogues, import) quand vide', () => { + const tree = buildCampaignTree('c1', data(), t); + expect(tree.map((n) => n.id)).toEqual([ + 'npcs-root', 'enemies-root', 'random-tables-root', + 'notebooks-root', 'item-catalogs-root', 'import-pdf-root', + ]); + }); + + it('trie les arcs en ordre NUMÉRIQUE naturel (1, 2, 10)', () => { + const arcs = [ + { id: 'a', name: '10. Final' }, + { id: 'b', name: '2. Voyage' }, + { id: 'c', name: '1. Intro' }, + ]; + const tree = buildCampaignTree('c1', data({ arcs: arcs as never }), t); + const arcLabels = tree.filter((n) => n.id?.startsWith('arc-')).map((n) => n.label); + expect(arcLabels).toEqual(['1. Intro', '2. Voyage', '10. Final']); + }); + + it('imbrique chapitres et scènes et marque les chapitres à prérequis', () => { + const tree = buildCampaignTree('camp', data({ + arcs: [{ id: 'a1', name: 'Arc', type: 'LINEAR' }] as never, + chaptersByArc: { a1: [{ id: 'c1', name: 'Chap', prerequisites: [{}] }] } as never, + scenesByChapter: { c1: [{ id: 's1', name: 'Scene' }] } as never, + }), t); + const arc = tree.find((n) => n.id === 'arc-a1')!; + const chapter = arc.children![0]; + expect(chapter.id).toBe('chapter-c1'); + expect(chapter.meta).toBe('🔒'); // cadenas si prérequis + expect(chapter.children![0].id).toBe('scene-s1'); + }); + + it('regroupe les PNJ par dossier et laisse les non classés à la racine', () => { + const npcs = [ + { id: 'n1', name: 'Alice', folder: 'Ville' }, + { id: 'n2', name: 'Bob', folder: 'Ville' }, + { id: 'n3', name: 'Carl' }, + ]; + const tree = buildCampaignTree('c', data({ npcs: npcs as never }), t); + const npcsRoot = tree.find((n) => n.id === 'npcs-root')!; + expect(npcsRoot.meta).toBe('3'); + const folder = npcsRoot.children!.find((c) => c.id === 'npc-folder-Ville')!; + expect(folder.children!.map((c) => c.label)).toEqual(['Alice', 'Bob']); + expect(npcsRoot.children!.some((c) => c.id === 'npc-n3')).toBe(true); + }); + + it("affiche le niveau de l'ennemi en méta", () => { + const tree = buildCampaignTree('c', data({ + enemies: [{ id: 'e1', name: 'Gobelin', level: 3 }] as never, + }), t); + const enemiesRoot = tree.find((n) => n.id === 'enemies-root')!; + expect(enemiesRoot.children![0].meta).toBe('Niv. 3'); + }); + + it('libelle « nouvelle quête » dans un arc HUB (vs « nouveau chapitre »)', () => { + const hub = buildCampaignTree('c', data({ arcs: [{ id: 'a', name: 'Hub', type: 'HUB' }] as never }), t) + .find((n) => n.id === 'arc-a')!; + expect(hub.createActions![0].label).toBe('campaignTree.newQuest'); + + const linear = buildCampaignTree('c', data({ arcs: [{ id: 'a', name: 'Lin', type: 'LINEAR' }] as never }), t) + .find((n) => n.id === 'arc-a')!; + expect(linear.createActions![0].label).toBe('campaignTree.newChapter'); + }); +}); diff --git a/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts b/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts index dc48374..8fe3bd7 100644 --- a/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts +++ b/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts @@ -329,7 +329,16 @@ export class GameSystemEditComponent implements OnInit { const valid = this.sections.filter(s => s.title.trim() || s.content.trim()); if (valid.length === 0) return null; return valid - .map(s => `## ${s.title.trim() || 'Sans titre'}\n${s.content.trim()}`) + .map(s => { + // Le "## " est RESERVE aux titres de section (parseMarkdown decoupe dessus, + // tout comme le parser Java cote backend). Or le contenu genere par l'IA + // contient souvent des sous-titres "## " : sans rien faire, ils seraient + // re-interpretes comme de nouvelles sections au rechargement -> explosion + // de sections + sections vides. On les retrograde donc en "### " (un niveau + // plus bas) pour garder un round-trip stable. (### / #### ne collisionnent pas.) + const content = s.content.trim().replace(/^##[ \t]+/gm, '### '); + return `## ${s.title.trim() || 'Sans titre'}\n${content}`; + }) .join('\n\n'); } } diff --git a/web/src/app/services/ai-chat.service.ts b/web/src/app/services/ai-chat.service.ts index 492b11c..9cd0f2e 100644 --- a/web/src/app/services/ai-chat.service.ts +++ b/web/src/app/services/ai-chat.service.ts @@ -1,7 +1,8 @@ import { Injectable, inject } from '@angular/core'; -import { Observable } from 'rxjs'; +import { Observable, Subscriber } from 'rxjs'; import { TranslateService } from '@ngx-translate/core'; import { LanguageService } from './language.service'; +import { parseSseStream, sseFetch } from '../shared/sse.util'; /** * Un message d'une conversation IA (vue front). @@ -105,108 +106,43 @@ export class AiChatService { /** Plumbing SSE mutualisé entre les endpoints (Lore / Campaign / Session). */ private streamSse(endpoint: string, body: Record): Observable { - return new Observable((subscriber) => { - const controller = new AbortController(); - - fetch(endpoint, { + return sseFetch( + endpoint, + { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream', 'X-User-Language': this.language.current }, - body: JSON.stringify(body), - signal: controller.signal - }) - .then(async (response) => { - if (!response.ok || !response.body) { - subscriber.error(new Error(`HTTP ${response.status}`)); - return; - } - await this.consumeSseStream(response.body, subscriber); - }) - .catch((err) => { - if (controller.signal.aborted) return; // annulation volontaire, silencieuse - subscriber.error(err); - }); - - return () => controller.abort(); - }); + body: JSON.stringify(body) + }, + (responseBody, subscriber) => this.consumeSseStream(responseBody, subscriber) + ); } - /** - * Consomme un ReadableStream SSE ligne par ligne. - * Format attendu (un événement = un bloc séparé par `\n\n`) : - * event: done (optionnel, défaut = 'message') - * data: {...} (une ou plusieurs lignes, concaténées avec '\n') - * (séparateur d'événements) - */ + /** Mappe les événements SSE bruts vers les `ChatStreamEvent` typés. */ private async consumeSseStream( body: ReadableStream, - subscriber: { next: (e: ChatStreamEvent) => void; error: (e: unknown) => void; complete: () => void } + subscriber: Subscriber ): Promise { - const reader = body.getReader(); - const decoder = new TextDecoder('utf-8'); - let buffer = ''; - - // Événement SSE en cours de construction (accumulé entre lignes vides). - let currentEvent: string | null = null; - let currentData = ''; - - const dispatchCurrentEvent = () => { - const eventName = currentEvent ?? 'message'; - // DEBUG jauge de contexte — à retirer une fois stabilisé. - if (eventName !== 'message') { - console.log('[AiChatService] SSE event:', eventName, 'data:', currentData); - } - if (eventName === 'error') { - const message = this.safeParseMessage(currentData); - subscriber.error(new Error(message)); - } else if (eventName === 'done') { - subscriber.next({ type: 'done' }); - subscriber.complete(); - } else if (eventName === 'usage') { - const usage = this.safeParseUsage(currentData); - if (usage) subscriber.next({ type: 'usage', usage }); - } else { - // Événement 'message' (défaut) : JSON {"token": "..."} - const token = this.safeParseToken(currentData); - if (token) subscriber.next({ type: 'token', value: token }); - } - currentEvent = null; - currentData = ''; - }; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - - // On découpe par lignes ; la dernière (potentiellement incomplète) reste dans buffer. - let newlineIdx: number; - while ((newlineIdx = buffer.indexOf('\n')) >= 0) { - const line = buffer.slice(0, newlineIdx).replace(/\r$/, ''); - buffer = buffer.slice(newlineIdx + 1); - - if (line === '') { - // Ligne vide = fin d'un événement SSE : on dispatch ce qu'on a accumulé. - if (currentEvent !== null || currentData !== '') { - dispatchCurrentEvent(); - } - continue; - } - if (line.startsWith('event:')) { - currentEvent = line.slice(6).trim(); - } else if (line.startsWith('data:')) { - const chunk = line.slice(5).replace(/^ /, ''); - currentData = currentData ? `${currentData}\n${chunk}` : chunk; - } - // Autres champs SSE (id:, retry:) ignorés pour le MVP. + await parseSseStream(body, ({ event, data }) => { + if (event === 'error') { + subscriber.error(new Error(this.safeParseMessage(data))); + } else if (event === 'done') { + subscriber.next({ type: 'done' }); + subscriber.complete(); + } else if (event === 'usage') { + const usage = this.safeParseUsage(data); + if (usage) subscriber.next({ type: 'usage', usage }); + } else { + // Événement 'message' (défaut) : JSON {"token": "..."} + const token = this.safeParseToken(data); + if (token) subscriber.next({ type: 'token', value: token }); } - } + }); // Fin de stream côté réseau sans event: done explicite → on complete quand même. - if (currentEvent !== null || currentData !== '') dispatchCurrentEvent(); subscriber.complete(); } catch (err) { subscriber.error(err); diff --git a/web/src/app/services/campaign-import.service.ts b/web/src/app/services/campaign-import.service.ts index 122d0d3..ff41b8a 100644 --- a/web/src/app/services/campaign-import.service.ts +++ b/web/src/app/services/campaign-import.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Observable, Subscriber } from 'rxjs'; import { TranslateService } from '@ngx-translate/core'; import { CampaignImportApplyResult, @@ -8,6 +8,7 @@ import { CampaignImportStreamEvent } from './campaign-import.model'; import { LanguageService } from './language.service'; +import { parseSseStream, sseFetch } from '../shared/sse.util'; /** * Service HTTP pour l'import d'un PDF de campagne. @@ -21,31 +22,17 @@ export class CampaignImportService { constructor(private http: HttpClient, private translate: TranslateService, private language: LanguageService) {} importStructureStream(campaignId: string, file: File): Observable { - return new Observable((subscriber) => { - const controller = new AbortController(); - const form = new FormData(); - form.append('file', file); - - fetch(`/api/campaigns/${campaignId}/import-structure/stream`, { + const form = new FormData(); + form.append('file', file); + return sseFetch( + `/api/campaigns/${campaignId}/import-structure/stream`, + { method: 'POST', headers: { 'Accept': 'text/event-stream', 'X-User-Language': this.language.current }, - body: form, - signal: controller.signal - }) - .then(async (response) => { - if (!response.ok || !response.body) { - subscriber.error(new Error(`HTTP ${response.status}`)); - return; - } - await this.consumeSse(response.body, subscriber); - }) - .catch((err) => { - if (controller.signal.aborted) return; - subscriber.error(err); - }); - - return () => controller.abort(); - }); + body: form + }, + (body, subscriber) => this.consumeSse(body, subscriber) + ); } applyStructure(campaignId: string, proposal: CampaignImportProposal): Observable { @@ -53,39 +40,33 @@ export class CampaignImportService { `/api/campaigns/${campaignId}/import-structure/apply`, proposal); } - /** Décode un ReadableStream SSE et émet les évènements d'import typés. */ + /** Mappe les évènements SSE bruts vers les évènements d'import typés. */ private async consumeSse( body: ReadableStream, - subscriber: { next: (e: CampaignImportStreamEvent) => void; error: (e: unknown) => void; complete: () => void } + subscriber: Subscriber ): Promise { - const reader = body.getReader(); - const decoder = new TextDecoder('utf-8'); - let buffer = ''; - let currentEvent: string | null = null; - let currentData = ''; // Le flux s'est-il terminé PROPREMENT (évènement done ou error reçu) ? // Sans ce suivi, une connexion coupée en plein import (timeout serveur, // proxy, Core redémarré) terminait l'Observable en silence : barre de // progression figée et aucun message pour l'utilisateur. let terminated = false; - const dispatch = () => { - const name = currentEvent ?? 'message'; - if (name === 'error') { + const dispatch = ({ event, data }: { event: string; data: string }) => { + if (event === 'error') { let message = this.translate.instant('services.importFailed'); - try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ } + try { message = (JSON.parse(data) as { message?: string }).message ?? message; } catch { /* défaut */ } terminated = true; subscriber.error(new Error(message)); - } else if (name === 'status') { + } else if (event === 'status') { // Message d'attente lisible (fournisseur saturé, morceau re-découpé…). try { - const obj = JSON.parse(currentData) as { message?: string }; + const obj = JSON.parse(data) as { message?: string }; if (obj.message) subscriber.next({ type: 'status', message: obj.message }); } catch { /* bloc malformé ignoré */ } - } else if (name === 'progress' || name === 'done') { + } else if (event === 'progress' || event === 'done') { try { - const obj = JSON.parse(currentData); - if (name === 'done') { + const obj = JSON.parse(data); + if (event === 'done') { terminated = true; subscriber.next({ type: 'done', arcs: obj.arcs ?? [], npcs: obj.npcs ?? [] }); subscriber.complete(); @@ -94,32 +75,10 @@ export class CampaignImportService { } } catch { /* bloc malformé ignoré */ } } - currentEvent = null; - currentData = ''; }; try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - let idx: number; - while ((idx = buffer.indexOf('\n')) >= 0) { - const line = buffer.slice(0, idx).replace(/\r$/, ''); - buffer = buffer.slice(idx + 1); - if (line === '') { - if (currentEvent !== null || currentData !== '') dispatch(); - continue; - } - if (line.startsWith('event:')) { - currentEvent = line.slice(6).trim(); - } else if (line.startsWith('data:')) { - const chunk = line.slice(5).replace(/^ /, ''); - currentData = currentData ? `${currentData}\n${chunk}` : chunk; - } - } - } - if (currentEvent !== null || currentData !== '') dispatch(); + await parseSseStream(body, dispatch); if (!terminated) { subscriber.error(new Error( this.translate.instant('services.importInterrupted'))); diff --git a/web/src/app/services/game-system.service.ts b/web/src/app/services/game-system.service.ts index a02fded..b05a481 100644 --- a/web/src/app/services/game-system.service.ts +++ b/web/src/app/services/game-system.service.ts @@ -1,9 +1,10 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Observable, Subscriber } from 'rxjs'; import { TranslateService } from '@ngx-translate/core'; import { GameSystem, GameSystemCreate, RulesImportResponse, RulesImportStreamEvent } from './game-system.model'; import { LanguageService } from './language.service'; +import { parseSseStream, sseFetch } from '../shared/sse.util'; /** * Service HTTP pour les GameSystems (systèmes de JDR). @@ -57,66 +58,46 @@ export class GameSystemService { * subscription annule le fetch (AbortController). */ importRulesStream(file: File): Observable { - return new Observable((subscriber) => { - const controller = new AbortController(); - const form = new FormData(); - form.append('file', file); - - fetch(`${this.apiUrl}/import-rules/stream`, { + const form = new FormData(); + form.append('file', file); + return sseFetch( + `${this.apiUrl}/import-rules/stream`, + { method: 'POST', headers: { 'Accept': 'text/event-stream', 'X-User-Language': this.language.current }, - body: form, - signal: controller.signal - }) - .then(async (response) => { - if (!response.ok || !response.body) { - subscriber.error(new Error(`HTTP ${response.status}`)); - return; - } - await this.consumeImportSse(response.body, subscriber); - }) - .catch((err) => { - if (controller.signal.aborted) return; - subscriber.error(err); - }); - - return () => controller.abort(); - }); + body: form + }, + (body, subscriber) => this.consumeImportSse(body, subscriber) + ); } - /** Décode un ReadableStream SSE et émet les évènements d'import typés. */ + /** Mappe les évènements SSE bruts vers les évènements d'import typés. */ private async consumeImportSse( body: ReadableStream, - subscriber: { next: (e: RulesImportStreamEvent) => void; error: (e: unknown) => void; complete: () => void } + subscriber: Subscriber ): Promise { - const reader = body.getReader(); - const decoder = new TextDecoder('utf-8'); - let buffer = ''; - let currentEvent: string | null = null; - let currentData = ''; // Le flux s'est-il terminé PROPREMENT (évènement done ou error reçu) ? // Sans ce suivi, une connexion coupée en plein import (timeout serveur, // proxy, Core redémarré) terminait l'Observable en silence : barre de // progression figée et aucun message pour l'utilisateur. let terminated = false; - const dispatch = () => { - const name = currentEvent ?? 'message'; - if (name === 'error') { + const dispatch = ({ event, data }: { event: string; data: string }) => { + if (event === 'error') { let message = this.translate.instant('services.importFailed'); - try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ } + try { message = (JSON.parse(data) as { message?: string }).message ?? message; } catch { /* garde le défaut */ } terminated = true; subscriber.error(new Error(message)); - } else if (name === 'status') { + } else if (event === 'status') { // Message d'attente lisible (fournisseur saturé, morceau re-découpé…). try { - const obj = JSON.parse(currentData) as { message?: string }; + const obj = JSON.parse(data) as { message?: string }; if (obj.message) subscriber.next({ type: 'status', message: obj.message }); } catch { /* bloc malformé ignoré */ } - } else if (name === 'progress' || name === 'done') { + } else if (event === 'progress' || event === 'done') { try { - const obj = JSON.parse(currentData); - if (name === 'done') { + const obj = JSON.parse(data); + if (event === 'done') { terminated = true; subscriber.next({ type: 'done', ...obj }); subscriber.complete(); @@ -125,32 +106,10 @@ export class GameSystemService { } } catch { /* bloc malformé ignoré */ } } - currentEvent = null; - currentData = ''; }; try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - let idx: number; - while ((idx = buffer.indexOf('\n')) >= 0) { - const line = buffer.slice(0, idx).replace(/\r$/, ''); - buffer = buffer.slice(idx + 1); - if (line === '') { - if (currentEvent !== null || currentData !== '') dispatch(); - continue; - } - if (line.startsWith('event:')) { - currentEvent = line.slice(6).trim(); - } else if (line.startsWith('data:')) { - const chunk = line.slice(5).replace(/^ /, ''); - currentData = currentData ? `${currentData}\n${chunk}` : chunk; - } - } - } - if (currentEvent !== null || currentData !== '') dispatch(); + await parseSseStream(body, dispatch); if (!terminated) { subscriber.error(new Error( this.translate.instant('services.importInterrupted'))); diff --git a/web/src/app/services/notebook.service.ts b/web/src/app/services/notebook.service.ts index 5db80a0..6704705 100644 --- a/web/src/app/services/notebook.service.ts +++ b/web/src/app/services/notebook.service.ts @@ -4,6 +4,7 @@ import { Observable } from 'rxjs'; import { TranslateService } from '@ngx-translate/core'; import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChatEvent } from './notebook.model'; import { LanguageService } from './language.service'; +import { parseSseStream } from '../shared/sse.util'; /** * Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources, @@ -88,20 +89,13 @@ export class NotebookService { this.zone.run(() => subscriber.complete()); return; } - const reader = response.body.getReader(); - const decoder = new TextDecoder('utf-8'); - let buffer = ''; - let currentEvent: string | null = null; - let currentData = ''; - - const dispatch = () => { - const name = currentEvent ?? 'message'; - if (name === 'token') { - try { emit({ type: 'token', value: (JSON.parse(currentData) as { token?: string }).token ?? '' }); } + await parseSseStream(response.body, ({ event, data }) => { + if (event === 'token') { + try { emit({ type: 'token', value: (JSON.parse(data) as { token?: string }).token ?? '' }); } catch { /* ignore */ } - } else if (name === 'sources') { + } else if (event === 'sources') { try { - const o = JSON.parse(currentData) as { sources?: { source_id?: string; page?: number | null; score?: number }[] }; + const o = JSON.parse(data) as { sources?: { source_id?: string; page?: number | null; score?: number }[] }; emit({ type: 'sources', sources: (o.sources ?? []).map(s => ({ @@ -109,39 +103,19 @@ export class NotebookService { })) }); } catch { /* ignore */ } - } else if (name === 'progress') { + } else if (event === 'progress') { try { - const o = JSON.parse(currentData) as { current?: number; total?: number }; + const o = JSON.parse(data) as { current?: number; total?: number }; emit({ type: 'progress', current: o.current ?? 0, total: o.total ?? 0 }); } catch { /* ignore */ } - } else if (name === 'done') { + } else if (event === 'done') { emit({ type: 'done' }); - } else if (name === 'error') { + } else if (event === 'error') { let msg = 'Erreur du chat.'; - try { msg = (JSON.parse(currentData) as { message?: string }).message ?? msg; } catch { /* garde */ } + try { msg = (JSON.parse(data) as { message?: string }).message ?? msg; } catch { /* garde */ } emit({ type: 'error', message: msg }); } - currentEvent = null; - currentData = ''; - }; - - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - let idx: number; - while ((idx = buffer.indexOf('\n')) >= 0) { - const line = buffer.slice(0, idx).replace(/\r$/, ''); - buffer = buffer.slice(idx + 1); - if (line === '') { if (currentEvent !== null || currentData !== '') dispatch(); continue; } - if (line.startsWith('event:')) currentEvent = line.slice(6).trim(); - else if (line.startsWith('data:')) { - const chunk = line.slice(5).replace(/^ /, ''); - currentData = currentData ? `${currentData}\n${chunk}` : chunk; - } - } - } - if (currentEvent !== null || currentData !== '') dispatch(); + }); this.zone.run(() => subscriber.complete()); } catch (err) { if ((err as Error).name !== 'AbortError') { diff --git a/web/src/app/shared/dice.utils.spec.ts b/web/src/app/shared/dice.utils.spec.ts new file mode 100644 index 0000000..b51208d --- /dev/null +++ b/web/src/app/shared/dice.utils.spec.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { DiceUtils } from './dice.utils'; + +describe('DiceUtils.parse', () => { + it('parse une formule complète', () => { + expect(DiceUtils.parse('2d6')).toEqual({ count: 2, faces: 6 }); + }); + + it('compte par défaut = 1 ("d20")', () => { + expect(DiceUtils.parse('d20')).toEqual({ count: 1, faces: 20 }); + }); + + it('tolère espaces et casse', () => { + expect(DiceUtils.parse(' 1 D 100 ')).toEqual({ count: 1, faces: 100 }); + }); + + it('rejette les formules invalides ou hors bornes', () => { + for (const f of ['', null, undefined, 'abc', '2x6', '0d6', '101d6', '1d1', '1d20000']) { + expect(DiceUtils.parse(f as string)).toBeNull(); + } + }); +}); + +describe('DiceUtils.roll', () => { + it('renvoie des jets dans la plage et un total = somme', () => { + const r = DiceUtils.roll('3d6'); + expect(r).not.toBeNull(); + expect(r!.rolls).toHaveLength(3); + for (const v of r!.rolls) { + expect(v).toBeGreaterThanOrEqual(1); + expect(v).toBeLessThanOrEqual(6); + } + expect(r!.total).toBe(r!.rolls.reduce((a, b) => a + b, 0)); + expect(r!.total).toBeGreaterThanOrEqual(3); + expect(r!.total).toBeLessThanOrEqual(18); + }); + + it('null si formule invalide', () => { + expect(DiceUtils.roll('nope')).toBeNull(); + }); +}); + +describe('DiceUtils.totalRange', () => { + it('2d6 -> {min:2, max:12}', () => { + expect(DiceUtils.totalRange('2d6')).toEqual({ min: 2, max: 12 }); + }); + + it('null si invalide', () => { + expect(DiceUtils.totalRange('x')).toBeNull(); + }); +}); + +describe('DiceUtils.randomInt', () => { + it('reste dans [min, max] sur de nombreux tirages', () => { + for (let i = 0; i < 200; i++) { + const v = DiceUtils.randomInt(1, 6); + expect(v).toBeGreaterThanOrEqual(1); + expect(v).toBeLessThanOrEqual(6); + } + }); +}); diff --git a/web/src/app/shared/sse.util.spec.ts b/web/src/app/shared/sse.util.spec.ts new file mode 100644 index 0000000..fbd9a9f --- /dev/null +++ b/web/src/app/shared/sse.util.spec.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { parseSseStream, sseFetch, SseEvent } from './sse.util'; + +/** Construit un ReadableStream à partir d'une chaîne (un seul chunk). */ +function streamFrom(text: string): ReadableStream { + const bytes = new TextEncoder().encode(text); + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +async function collect(text: string): Promise { + const events: SseEvent[] = []; + await parseSseStream(streamFrom(text), (e) => events.push(e)); + return events; +} + +describe('parseSseStream', () => { + it('parse un événement nommé avec sa data', async () => { + expect(await collect('event: token\ndata: {"token":"hi"}\n\n')) + .toEqual([{ event: 'token', data: '{"token":"hi"}' }]); + }); + + it("nomme l'événement par défaut 'message'", async () => { + expect(await collect('data: {"token":"x"}\n\n')) + .toEqual([{ event: 'message', data: '{"token":"x"}' }]); + }); + + it('sépare plusieurs événements', async () => { + const events = await collect('event: a\ndata: 1\n\nevent: b\ndata: 2\n\n'); + expect(events).toEqual([ + { event: 'a', data: '1' }, + { event: 'b', data: '2' }, + ]); + }); + + it('concatène les lignes data multiples avec des \\n', async () => { + const events = await collect('data: ligne1\ndata: ligne2\n\n'); + expect(events[0].data).toBe('ligne1\nligne2'); + }); + + it('gère les fins de ligne CRLF', async () => { + expect(await collect('event: x\r\ndata: y\r\n\r\n')) + .toEqual([{ event: 'x', data: 'y' }]); + }); + + it('ignore les lignes de commentaire keep-alive (`: ...`)', async () => { + expect(await collect(': keep-alive\n\ndata: ok\n\n')) + .toEqual([{ event: 'message', data: 'ok' }]); + }); + + it('dispatche un bloc résiduel en fin de flux sans ligne vide finale', async () => { + // Lignes terminées par \n mais pas de ligne vide séparatrice finale. + expect(await collect('event: done\ndata: {}\n')) + .toEqual([{ event: 'done', data: '{}' }]); + }); + + it('ne dispatche rien sur un flux vide', async () => { + expect(await collect('')).toEqual([]); + }); +}); + +describe('sseFetch', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("consomme le corps et expose les évènements jusqu'à complétion", async () => { + const body = streamFrom('event: done\ndata: {}\n\n'); + vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status: 200 }))); + + const seen: string[] = []; + await new Promise((resolve, reject) => { + sseFetch('/x', { method: 'POST' }, async (b, sub) => { + await parseSseStream(b, (e) => seen.push(e.event)); + sub.next('mapped'); + sub.complete(); + }).subscribe({ next: (v) => seen.push(v), complete: resolve, error: reject }); + }); + + expect(seen).toContain('done'); + expect(seen).toContain('mapped'); + }); + + it('émet une erreur HTTP si la réponse est non-ok', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 500 }))); + + await expect( + new Promise((resolve, reject) => { + sseFetch('/x', {}, async () => undefined).subscribe({ + error: reject, + complete: resolve, + }); + }), + ).rejects.toThrow('HTTP 500'); + }); + + it('annule le fetch quand on se désabonne', async () => { + let abortedSignal: AbortSignal | undefined; + vi.stubGlobal('fetch', vi.fn((_url: string, init: RequestInit) => { + abortedSignal = init.signal as AbortSignal; + return new Promise(() => { /* ne résout jamais : on teste l'annulation */ }); + })); + + const sub = sseFetch('/x', {}, async () => undefined).subscribe(); + sub.unsubscribe(); + expect(abortedSignal?.aborted).toBe(true); + }); +}); diff --git a/web/src/app/shared/sse.util.ts b/web/src/app/shared/sse.util.ts new file mode 100644 index 0000000..b39aff7 --- /dev/null +++ b/web/src/app/shared/sse.util.ts @@ -0,0 +1,110 @@ +import { Observable, Subscriber } from 'rxjs'; + +/** + * Plumbing SSE mutualisé entre les services qui streament depuis le backend + * (chat IA, import de campagne, import de règles, chat d'atelier). + * + * On n'utilise pas `EventSource` (API navigateur native) : elle ne supporte que + * GET sans body, alors qu'on a besoin de POST (+ multipart). On fait donc un + * `fetch()` dont on décode le `ReadableStream` ligne par ligne. + */ + +/** Un événement SSE complet (bloc séparé par une ligne vide). */ +export interface SseEvent { + /** Nom de l'événement (`event:` du flux), ou `'message'` par défaut. */ + event: string; + /** Charge utile (`data:`) ; les lignes multiples sont concaténées par `\n`. */ + data: string; +} + +/** + * Décode un `ReadableStream` SSE et invoque `onEvent` pour chaque bloc complet. + * + * Format attendu (un événement = un bloc terminé par une ligne vide) : + * event: done (optionnel, défaut = 'message') + * data: {...} (une ou plusieurs lignes, concaténées avec '\n') + * (séparateur d'événements) + * + * Gère le buffer partiel entre deux lectures, les fins de ligne `\r\n`, et + * dispatche un éventuel bloc résiduel en fin de flux (réseau coupé sans ligne + * vide finale). Ne complète/erreur PAS d'observable : l'appelant mappe chaque + * `SseEvent` vers son type métier et décide de la terminaison. + */ +export async function parseSseStream( + body: ReadableStream, + onEvent: (ev: SseEvent) => void +): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + let currentEvent: string | null = null; + let currentData = ''; + + const flush = () => { + if (currentEvent === null && currentData === '') return; + onEvent({ event: currentEvent ?? 'message', data: currentData }); + currentEvent = null; + currentData = ''; + }; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + // On découpe par lignes ; la dernière (potentiellement incomplète) reste dans buffer. + let newlineIdx: number; + while ((newlineIdx = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, newlineIdx).replace(/\r$/, ''); + buffer = buffer.slice(newlineIdx + 1); + + if (line === '') { + flush(); // ligne vide = fin d'un événement SSE + continue; + } + if (line.startsWith('event:')) { + currentEvent = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + const chunk = line.slice(5).replace(/^ /, ''); + currentData = currentData ? `${currentData}\n${chunk}` : chunk; + } + // Autres champs SSE (id:, retry:) ignorés. + } + } + // Fin de stream sans ligne vide finale : on dispatche le dernier bloc accumulé. + flush(); +} + +/** + * Ouvre un flux SSE via `fetch()` et expose ses événements dans un `Observable`. + * + * Annuler la souscription annule proprement le fetch (AbortController) ; une + * erreur réseau survenue APRÈS une annulation volontaire est ignorée. Le rappel + * `consume` reçoit le corps de la réponse et le subscriber : il mappe les + * événements bruts vers le type métier `T` et complète/erreur le subscriber + * (typiquement via {@link parseSseStream}). + */ +export function sseFetch( + url: string, + init: RequestInit, + consume: (body: ReadableStream, subscriber: Subscriber) => Promise +): Observable { + return new Observable((subscriber) => { + const controller = new AbortController(); + + fetch(url, { ...init, signal: controller.signal }) + .then(async (response) => { + if (!response.ok || !response.body) { + subscriber.error(new Error(`HTTP ${response.status}`)); + return; + } + await consume(response.body, subscriber); + }) + .catch((err) => { + if (controller.signal.aborted) return; // annulation volontaire, silencieuse + subscriber.error(err); + }); + + return () => controller.abort(); + }); +} diff --git a/web/tsconfig.json b/web/tsconfig.json index 8c5be9f..680f171 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -30,6 +30,8 @@ }, "exclude": [ "e2e/**/*", - "playwright.config.ts" + "playwright.config.ts", + "vitest.config.ts", + "src/**/*.spec.ts" ] } diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..06addcc --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from 'vitest/config'; + +/** + * Runner des tests UNITAIRES de logique PURE (TypeScript sans TestBed Angular) : + * helpers/utils et services dont on teste les fonctions pures. Exécution en Node + * (rapide, pas de navigateur). Les specs sont en `*.spec.ts` à côté du code. + * + * Lancement : `npm run test:unit` ; couverture : `npm run test:unit:coverage` + * (rapport HTML dans coverage/ — l'équivalent JaCoCo). + * + * Hors du périmètre `ng build` (les .spec.ts sont exclus dans tsconfig.json). + */ +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.spec.ts'], + globals: true, + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + // Périmètre de couverture = les fichiers effectivement couverts par des + // specs. Ajouter ici chaque module au fur et à mesure qu'on le teste. + include: [ + 'src/app/shared/sse.util.ts', + 'src/app/shared/dice.utils.ts', + 'src/app/campaigns/campaign-tree.helper.ts', + ], + // Plancher ANTI-RÉGRESSION (sous les valeurs actuelles : ~75% lignes). + // campaign-tree.helper tire la moyenne vers le bas (loadCampaignTreeData / + // buildCampaignSidebarConfig nécessitent des services Angular → non + // unit-testables ici). À remonter à mesure que la couverture progresse. + thresholds: { + statements: 70, + lines: 70, + branches: 70, + functions: 45, + }, + }, + }, +});