12 Commits

Author SHA1 Message Date
e3c8232e38 Version 0.6.1
All checks were successful
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m19s
Build & Push Images / build (web) (push) Successful in 1m31s
2026-04-23 14:36:09 +02:00
a4df9fc759 Ajout des personnage dans la sidebar de la campagne 2026-04-23 14:34:07 +02:00
f1989c1d77 Mutualisation de la version pour ne pas l'oublier dans le footer du front ;
All checks were successful
Build & Push Images / build (brain) (push) Successful in 50s
Build & Push Images / build (core) (push) Successful in 1m29s
Build & Push Images / build (web) (push) Successful in 1m24s
Passage en version v 0.6.0
2026-04-23 14:12:24 +02:00
8efdf5d0e0 Correction bug suppression complète coté lore (et suppression dans tout ce qui est campagne de la partie lore liée).
Améliorations ux :
- Bandeau en haut qui reste accessible lors de la création d'un élément (chapitre, page, scène etc...)
- Mise en place d'un surlignage pour voir su quel élément on est positionné
2026-04-23 14:06:50 +02:00
96bc5de942 Mise en place de la possibilité de supprimer des lores / campagnes d'un seul coup 2026-04-23 11:51:03 +02:00
84ccdd53ad Corrections d'ordre graphique / ergonomique :
- Lorsqu'on part de zéro : la création de dossier / page / template ce fait de manière plus fluide à la création d'un lore (par exemple création de page sans template et dossier : parcours facilité)
- Ajout d'un bouton "+" dans le header templates
- Harmonisation création / modification template

Correction de tests unitaires
2026-04-23 11:25:58 +02:00
29978058ee Correction d'un test unitaire
All checks were successful
Build & Push Images / build (brain) (push) Successful in 49s
Build & Push Images / build (core) (push) Successful in 1m24s
Build & Push Images / build (web) (push) Successful in 1m22s
2026-04-22 13:38:48 +02:00
e510f64336 Passage en v 0.5.0
Some checks failed
Build & Push Images / build (brain) (push) Successful in 47s
Build & Push Images / build (core) (push) Failing after 1m19s
Build & Push Images / build (web) (push) Successful in 1m23s
2026-04-22 13:33:47 +02:00
f189f67aaf Mise en place d'un bouton + au hover plutot qu'un affichage constant 2026-04-22 13:31:06 +02:00
8efa148739 Corrections visuel ; optimisation du chargement des pages (préchargement anticité, sinon temps de latence chaque fois qu'on visite un type de page une première fois) 2026-04-22 13:17:05 +02:00
8f4dd3e9d6 Ajout de la partie "Système de jeu" avec toute la partie stockage de règles de notre jeu.
Ajout de possibilité de stocker des fiches de personnages associés à une campagne également (personnages joueurs pour le moment)
2026-04-22 11:58:50 +02:00
bf38b6695f Ecriture de tests unitaires coté java pour améliorer la stabilité de l'application 2026-04-22 07:46:24 +02:00
189 changed files with 10442 additions and 602 deletions

View File

@@ -20,6 +20,8 @@ from app.domain.models import (
CampaignStructuralContext,
ChatMessage,
ChapterSummary,
CharacterSummary,
GameSystemContext,
LoreStructuralContext,
NarrativeEntityContext,
PageContext,
@@ -63,16 +65,17 @@ class ChatUseCase:
page_context: PageContext | None = None,
campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None,
) -> AsyncIterator[str]:
"""Streame les tokens de la réponse assistant pour le dernier message user.
Les 4 contextes sont tous optionnels, mais au moins l'un des deux
Les contextes sont tous optionnels, mais au moins l'un des deux
"niveaux haut" (lore_context ou campaign_context) doit être fourni
pour que le prompt ait du sens. Le controller (main.py) applique
cette règle à la frontière HTTP.
"""
system_prompt = self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity
lore_context, page_context, campaign_context, narrative_entity, game_system_context
)
async for token in self._llm.stream_chat(
messages,
@@ -87,12 +90,13 @@ class ChatUseCase:
page_context: PageContext | None = None,
campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None,
) -> str:
"""Version publique — utilisée par le controller HTTP pour compter
les tokens du system prompt avant de streamer (jauge de contexte).
"""
return self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity
lore_context, page_context, campaign_context, narrative_entity, game_system_context
)
# --- Construction du system prompt --------------------------------------
@@ -103,12 +107,15 @@ class ChatUseCase:
page: PageContext | None,
campaign: CampaignStructuralContext | None,
narrative: NarrativeEntityContext | None,
game_system: GameSystemContext | None = None,
) -> str:
sections = [_BASE_SYSTEM]
if lore is not None:
sections.append(self._format_lore(lore))
if campaign is not None:
sections.append(self._format_campaign(campaign, lore_present=lore is not None))
if game_system is not None:
sections.append(self._format_game_system(game_system))
if page is not None:
sections.append(self._format_page(page))
if narrative is not None:
@@ -190,14 +197,40 @@ class ChatUseCase:
if lore_present
else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)"
)
characters_block = ChatUseCase._format_characters(ctx.characters)
return (
"--- CAMPAGNE COURANTE ---\n"
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n\n"
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n"
f"{characters_block}\n"
"Structure narrative (les flèches → indiquent des transitions de scène "
"déclenchées par un choix des joueurs) :\n"
f"{arcs_block}"
)
@staticmethod
def _format_characters(characters: list[CharacterSummary]) -> str:
"""Bloc PJ — liste nom + snippet. Rappel anti-hallucination IA.
Si la campagne n'a aucun PJ, on le signale explicitement : l'IA ne
doit pas inventer "les héros" ou leurs noms dans ses suggestions.
"""
if not characters:
return (
"\nPersonnages joueurs : aucune fiche pour l'instant. Ne suppose "
"ni noms ni classes pour les PJ tant que le MJ ne les a pas créés.\n"
)
lines = ["\nPersonnages joueurs (PJ) :"]
for c in characters:
if c.snippet:
lines.append(f"- **{c.name}** — {c.snippet}")
else:
lines.append(f"- **{c.name}** (fiche vide)")
lines.append(
"Pour une fiche complète (stats, backstory), n'invente rien : "
"demande au MJ d'ouvrir l'éditeur du PJ pour te donner les détails."
)
return "\n".join(lines) + "\n"
@staticmethod
def _format_arcs(arcs: list[ArcSummary]) -> str:
if not arcs:
@@ -248,12 +281,46 @@ class ChatUseCase:
noun = "illustration" if count == 1 else "illustrations"
return f" [{count} {noun}]"
# --- Bloc Système de JDR ------------------------------------------------
@staticmethod
def _format_game_system(gs: GameSystemContext) -> str:
"""Bloc des règles du système de JDR de la campagne.
Les sections ont été filtrées côté Core selon l'intent (combat,
classes, lore...). Si aucune section n'a matché, on affiche juste
le nom du système comme rappel de cadre.
"""
desc = f"\nDescription : {gs.system_description}" if gs.system_description else ""
if not gs.sections:
return (
"--- SYSTÈME DE JDR ---\n"
f"Nom : {gs.system_name}{desc}\n"
"(Aucune section de règles pertinente pour ce type de génération — "
"reste cohérent avec l'univers et les conventions du système.)"
)
sections_block = "\n\n".join(
f"### {title}\n{content}" for title, content in gs.sections.items()
)
return (
"--- SYSTÈME DE JDR ---\n"
f"Nom : {gs.system_name}{desc}\n\n"
"Respecte scrupuleusement les règles et conventions ci-dessous quand "
"tu proposes des stats, classes, rencontres, mécaniques ou éléments "
"d'ambiance. Les noms propres (classes, sorts, monstres) doivent "
"venir de ces règles — n'en invente pas d'autres.\n\n"
f"{sections_block}"
)
@staticmethod
def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
type_label = {"arc": "ARC", "chapter": "CHAPITRE", "scene": "SCÈNE"}.get(
ne.entity_type.lower(), ne.entity_type.upper()
)
type_label = {
"arc": "ARC",
"chapter": "CHAPITRE",
"scene": "SCÈNE",
"character": "FICHE DE PERSONNAGE",
}.get(ne.entity_type.lower(), ne.entity_type.upper())
if ne.fields:
fields_block = "\n".join(
f'- "{key}" : {value or "(vide)"}'

View File

@@ -169,6 +169,20 @@ class CampaignStructuralContext:
campaign_name: str
campaign_description: str | None
arcs: list[ArcSummary]
characters: list["CharacterSummary"] = field(default_factory=list)
@dataclass(frozen=True)
class CharacterSummary:
"""Résumé d'un PJ : nom + snippet court extrait du markdown de la fiche.
La fiche complète n'est JAMAIS dans ce résumé — elle n'arrive que si le PJ
est l'entité focus (via NarrativeEntityContext entity_type="character").
Ça plafonne le coût token à ~40 tokens/PJ quel que soit le détail des fiches.
"""
name: str
snippet: str
@dataclass(frozen=True)
@@ -184,3 +198,20 @@ class NarrativeEntityContext:
entity_type: str
title: str
fields: dict[str, str]
@dataclass(frozen=True)
class GameSystemContext:
"""Règles d'un système de JDR (D&D, Nimble, homebrew...) injectées
dans le system prompt pour que l'IA respecte les mécaniques du jeu.
Les sections ont été présélectionnées côté Core selon l'intent
(SCENE → combat/PNJ, CHAPTER → combat/classes, ARC → lore/factions,
GENERIC → toutes). Indexées par titre H2 original.
Campagne uniquement au MVP : jamais présent sur un chat Lore.
"""
system_name: str
system_description: str | None
sections: dict[str, str]

View File

@@ -22,7 +22,9 @@ from app.domain.models import (
ArcSummary,
CampaignStructuralContext,
ChapterSummary,
CharacterSummary,
ChatMessage,
GameSystemContext,
LoreStructuralContext,
NarrativeEntityContext,
PageContext,
@@ -38,7 +40,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.4.0",
version="0.6.1",
)
@@ -196,22 +198,42 @@ class ArcSummaryDTO(BaseModel):
illustration_count: int = 0
class CharacterSummaryDTO(BaseModel):
"""Résumé d'un PJ : nom + snippet. Pas de fiche complète au niveau résumé."""
name: str
snippet: str = ""
class CampaignContextDTO(BaseModel):
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
campaign_name: str
campaign_description: str | None = None
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
characters: list[CharacterSummaryDTO] = Field(default_factory=list)
class NarrativeEntityDTO(BaseModel):
"""Entité narrative (arc/chapter/scene) en cours d'édition — focus optionnel."""
"""Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel."""
entity_type: str = Field(pattern="^(arc|chapter|scene)$")
entity_type: str = Field(pattern="^(arc|chapter|scene|character)$")
title: str
fields: dict[str, str] = Field(default_factory=dict)
class GameSystemContextDTO(BaseModel):
"""Règles de JDR présélectionnées par le Core (filtrées par intent).
Les sections sont un dict titre_H2 → contenu_markdown. Peuvent être
vides si aucune section ne matchait l'intent de génération courant.
"""
system_name: str
system_description: str | None = None
sections: dict[str, str] = Field(default_factory=dict)
class ChatStreamRequestDTO(BaseModel):
"""Requête de chat streamé : historique + contextes structurels.
@@ -226,6 +248,7 @@ class ChatStreamRequestDTO(BaseModel):
page_context: PageContextDTO | None = None
campaign_context: CampaignContextDTO | None = None
narrative_entity: NarrativeEntityDTO | None = None
game_system_context: GameSystemContextDTO | None = None
def has_scope(self) -> bool:
"""Vrai si au moins un contexte racine (Lore ou Campagne) est fourni."""
@@ -352,6 +375,7 @@ async def chat_stream(
page_context = _to_page_context(body.page_context)
campaign_context = _to_campaign_context(body.campaign_context)
narrative_entity = _to_narrative_entity(body.narrative_entity)
game_system_context = _to_game_system_context(body.game_system_context)
# --- Comptage tokens pour la jauge de contexte frontend ---
# On construit le system prompt une fois ici pour le compter — le use case
@@ -363,6 +387,7 @@ async def chat_stream(
page_context=page_context,
campaign_context=campaign_context,
narrative_entity=narrative_entity,
game_system_context=game_system_context,
)
# Dernier message = "current" (souvent user), le reste = historique accumulé.
current_msg = messages[-1] if messages else None
@@ -386,6 +411,7 @@ async def chat_stream(
page_context=page_context,
campaign_context=campaign_context,
narrative_entity=narrative_entity,
game_system_context=game_system_context,
):
# json.dumps avec ensure_ascii=False pour préserver les accents
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
@@ -523,10 +549,15 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo
)
for arc in dto.arcs
]
characters = [
CharacterSummary(name=c.name, snippet=c.snippet)
for c in dto.characters
]
return CampaignStructuralContext(
campaign_name=dto.campaign_name,
campaign_description=dto.campaign_description,
arcs=arcs,
characters=characters,
)
@@ -742,3 +773,13 @@ def _to_narrative_entity(dto: NarrativeEntityDTO | None) -> NarrativeEntityConte
title=dto.title,
fields=dict(dto.fields),
)
def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemContext | None:
if dto is None:
return None
return GameSystemContext(
system_name=dto.system_name,
system_description=dto.system_description,
sections=dict(dto.sections),
)

11
core/lombok.config Normal file
View File

@@ -0,0 +1,11 @@
## LoreMind Core - Configuration Lombok
#
# addLombokGeneratedAnnotation : ajoute @lombok.Generated sur toutes les
# methodes generees par Lombok (equals, hashCode, toString, builders,
# getters/setters, etc.). JaCoCo 0.8.2+ reconnait cette annotation et
# exclut automatiquement ces methodes du rapport de couverture.
#
# Objectif : mesurer la couverture UNIQUEMENT sur le code que nous ecrivons,
# pas sur le bytecode auto-genere (qui fausse les metriques : branches et
# instructions gonflees par les equals/hashCode).
lombok.addLombokGeneratedAnnotation = true

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.4.0</version>
<version>0.6.1</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>

View File

@@ -1,9 +1,13 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@@ -17,11 +21,20 @@ import java.util.Optional;
public class ArcService {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
public ArcService(ArcRepository arcRepository) {
public ArcService(ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
}
/** Compte des entités qui seront supprimées en cascade avec l'arc. */
public record DeletionImpact(int chapters, int scenes) {}
public Arc createArc(String name, String description, String campaignId, int order) {
Arc arc = Arc.builder()
.name(name)
@@ -59,7 +72,31 @@ public class ArcService {
return arcRepository.save(arc);
}
/**
* Calcule l'impact d'une suppression en cascade : chapitres + scènes
* qui disparaîtront avec l'arc.
*/
public DeletionImpact getDeletionImpact(String id) {
List<Chapter> chapters = chapterRepository.findByArcId(id);
int sceneTotal = 0;
for (Chapter chapter : chapters) {
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
}
return new DeletionImpact(chapters.size(), sceneTotal);
}
/**
* Supprime l'arc et toutes ses entités dépendantes (chapitres → scènes).
* Transactionnel : atomique.
*/
@Transactional
public void deleteArc(String id) {
for (Chapter chapter : chapterRepository.findByArcId(id)) {
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
sceneRepository.deleteById(scene.getId());
}
chapterRepository.deleteById(chapter.getId());
}
arcRepository.deleteById(id);
}

View File

@@ -1,8 +1,15 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@@ -16,9 +23,22 @@ import java.util.Optional;
public class CampaignService {
private final CampaignRepository campaignRepository;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository;
public CampaignService(CampaignRepository campaignRepository) {
public CampaignService(
CampaignRepository campaignRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
CharacterRepository characterRepository) {
this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository;
}
/**
@@ -28,13 +48,20 @@ public class CampaignService {
*
* <p>{@code loreId} est nullable : une campagne peut exister sans univers associé.</p>
*/
public record CampaignData(String name, String description, String loreId) {}
public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
/**
* Compte des entités qui seront supprimées en cascade si la campagne est effacée.
* Utilisé par l'UI pour afficher un récapitulatif dans le dialogue de confirmation.
*/
public record DeletionImpact(int arcs, int chapters, int scenes, int characters) {}
public Campaign createCampaign(CampaignData data) {
Campaign campaign = Campaign.builder()
.name(data.name())
.description(data.description())
.loreId(normalizeLoreId(data.loreId()))
.loreId(normalizeId(data.loreId()))
.gameSystemId(normalizeId(data.gameSystemId()))
.arcsCount(0)
.build();
return campaignRepository.save(campaign);
@@ -57,19 +84,61 @@ public class CampaignService {
Campaign campaign = existingCampaign.get();
campaign.setName(data.name());
campaign.setDescription(data.description());
campaign.setLoreId(normalizeLoreId(data.loreId()));
campaign.setLoreId(normalizeId(data.loreId()));
campaign.setGameSystemId(normalizeId(data.gameSystemId()));
return campaignRepository.save(campaign);
}
/**
* Normalise un loreId entrant : une chaîne vide/blanche est traitée comme "pas de lien".
* Normalise un ID entrant : une chaîne vide/blanche est traitée comme "pas de lien".
* Utile car les payloads JSON peuvent envoyer "" au lieu de null.
*/
private String normalizeLoreId(String loreId) {
return (loreId == null || loreId.isBlank()) ? null : loreId;
private String normalizeId(String id) {
return (id == null || id.isBlank()) ? null : id;
}
/**
* Calcule l'impact d'une suppression en cascade : nombre d'arcs, chapitres,
* scènes et personnages qui disparaîtront avec la campagne. Utilisé par l'UI
* pour afficher "X arcs, Y chapitres, Z scènes seront supprimés".
*/
public DeletionImpact getDeletionImpact(String id) {
List<Arc> arcs = arcRepository.findByCampaignId(id);
int chapterTotal = 0;
int sceneTotal = 0;
for (Arc arc : arcs) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
chapterTotal += chapters.size();
for (Chapter chapter : chapters) {
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
}
}
int characterTotal = characterRepository.findByCampaignId(id).size();
return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, characterTotal);
}
/**
* Supprime la campagne et toutes ses entités dépendantes (arcs → chapitres →
* scènes, plus les personnages). L'opération est transactionnelle : soit
* tout disparaît, soit rien ne change. Les FKs applicatives n'ayant pas
* de contrainte CASCADE au niveau DB, on orchestre la cascade ici.
*/
@Transactional
public void deleteCampaign(String id) {
List<Arc> arcs = arcRepository.findByCampaignId(id);
for (Arc arc : arcs) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
for (Chapter chapter : chapters) {
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
sceneRepository.deleteById(scene.getId());
}
chapterRepository.deleteById(chapter.getId());
}
arcRepository.deleteById(arc.getId());
}
for (var character : characterRepository.findByCampaignId(id)) {
characterRepository.deleteById(character.getId());
}
campaignRepository.deleteById(id);
}

View File

@@ -2,8 +2,10 @@ package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@@ -17,11 +19,16 @@ import java.util.Optional;
public class ChapterService {
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
public ChapterService(ChapterRepository chapterRepository) {
public ChapterService(ChapterRepository chapterRepository, SceneRepository sceneRepository) {
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
}
/** Compte des scènes qui seront supprimées en cascade avec le chapitre. */
public record DeletionImpact(int scenes) {}
public Chapter createChapter(String name, String description, String arcId, int order) {
Chapter chapter = Chapter.builder()
.name(name)
@@ -58,7 +65,17 @@ public class ChapterService {
return chapterRepository.save(chapter);
}
/** Compte des scènes qui tomberont avec le chapitre. */
public DeletionImpact getDeletionImpact(String id) {
return new DeletionImpact(sceneRepository.findByChapterId(id).size());
}
/** Supprime le chapitre et toutes ses scènes. Transactionnel : atomique. */
@Transactional
public void deleteChapter(String id) {
for (var scene : sceneRepository.findByChapterId(id)) {
sceneRepository.deleteById(scene.getId());
}
chapterRepository.deleteById(id);
}

View File

@@ -0,0 +1,72 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour les fiches de personnages (PJ).
*/
@Service
public class CharacterService {
private final CharacterRepository characterRepository;
public CharacterService(CharacterRepository characterRepository) {
this.characterRepository = characterRepository;
}
/**
* Parameter Object pour la création / mise à jour d'un Character.
* `order` est fourni par le controller ; si absent, le service le calcule.
*/
public record CharacterData(String name, String markdownContent, String campaignId, Integer order) {}
public Character createCharacter(CharacterData data) {
int order = data.order() != null
? data.order()
: nextOrderFor(data.campaignId());
Character character = Character.builder()
.name(data.name())
.markdownContent(data.markdownContent())
.campaignId(data.campaignId())
.order(order)
.build();
return characterRepository.save(character);
}
public Optional<Character> getCharacterById(String id) {
return characterRepository.findById(id);
}
public List<Character> getCharactersByCampaignId(String campaignId) {
return characterRepository.findByCampaignId(campaignId);
}
public Character updateCharacter(String id, CharacterData data) {
Character existing = characterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
existing.setName(data.name());
existing.setMarkdownContent(data.markdownContent());
if (data.order() != null) {
existing.setOrder(data.order());
}
// campaignId n'est pas modifiable après création (cross-campagne move hors scope MVP).
return characterRepository.save(existing);
}
public void deleteCharacter(String id) {
characterRepository.deleteById(id);
}
/** Renvoie la prochaine position libre — append en fin de liste. */
private int nextOrderFor(String campaignId) {
return characterRepository.findByCampaignId(campaignId).stream()
.mapToInt(Character::getOrder)
.max()
.orElse(-1) + 1;
}
}

View File

@@ -0,0 +1,92 @@
package com.loremind.application.gamesystemcontext;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.GenerationIntent;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.generationcontext.GameSystemContext;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Construit un {@link GameSystemContext} à partir d'un gameSystemId et d'un intent.
* <p>
* Pipeline :
* 1. Charge le GameSystem (retourne Optional.empty si introuvable — dégradation gracieuse).
* 2. Parse le markdown par titres H2 (## Section) → Map<Titre, Contenu>.
* 3. Filtre les sections selon l'intent via les alias {@link GenerationIntent#getSectionAliases()}.
* GENERIC = pas de filtre.
* <p>
* Parsing à la volée (pas de cache) : les règles d'un système font
* typiquement 5-20kB, le coût de parsing est négligeable devant l'appel LLM.
*/
@Service
public class GameSystemContextBuilder {
/** Matche "## Titre" en début de ligne (multiline). Capture le titre en groupe 1. */
private static final Pattern H2_HEADER = Pattern.compile("(?m)^##\\s+(.+?)\\s*$");
private final GameSystemRepository gameSystemRepository;
public GameSystemContextBuilder(GameSystemRepository gameSystemRepository) {
this.gameSystemRepository = gameSystemRepository;
}
public Optional<GameSystemContext> buildOptional(String gameSystemId, GenerationIntent intent) {
if (gameSystemId == null || gameSystemId.isBlank()) return Optional.empty();
return gameSystemRepository.findById(gameSystemId)
.map(gs -> build(gs, intent));
}
private GameSystemContext build(GameSystem gs, GenerationIntent intent) {
Map<String, String> allSections = parseH2Sections(gs.getRulesMarkdown());
Map<String, String> filtered = filterByIntent(allSections, intent);
return GameSystemContext.builder()
.systemName(gs.getName())
.systemDescription(gs.getDescription())
.sections(filtered)
.build();
}
/**
* Découpe le markdown par titres H2. Préserve l'ordre d'apparition (LinkedHashMap).
* Le contenu avant le premier H2 est ignoré (préambule libre).
*/
Map<String, String> parseH2Sections(String markdown) {
Map<String, String> sections = new LinkedHashMap<>();
if (markdown == null || markdown.isBlank()) return sections;
Matcher m = H2_HEADER.matcher(markdown);
String currentTitle = null;
int currentContentStart = -1;
while (m.find()) {
if (currentTitle != null) {
sections.put(currentTitle, markdown.substring(currentContentStart, m.start()).strip());
}
currentTitle = m.group(1).trim();
currentContentStart = m.end();
}
if (currentTitle != null) {
sections.put(currentTitle, markdown.substring(currentContentStart).strip());
}
return sections;
}
private Map<String, String> filterByIntent(Map<String, String> sections, GenerationIntent intent) {
if (intent.matchesAllSections()) return sections;
Map<String, String> filtered = new LinkedHashMap<>();
for (Map.Entry<String, String> e : sections.entrySet()) {
String titleLower = e.getKey().toLowerCase();
boolean match = intent.getSectionAliases().stream().anyMatch(titleLower::contains);
if (match) {
filtered.put(e.getKey(), e.getValue());
}
}
return filtered;
}
}

View File

@@ -0,0 +1,76 @@
package com.loremind.application.gamesystemcontext;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class GameSystemService {
private final GameSystemRepository gameSystemRepository;
public GameSystemService(GameSystemRepository gameSystemRepository) {
this.gameSystemRepository = gameSystemRepository;
}
/**
* Parameter Object pour la création / mise à jour d'un GameSystem.
*/
public record GameSystemData(
String name,
String description,
String rulesMarkdown,
String author,
boolean isPublic
) {}
public GameSystem createGameSystem(GameSystemData data) {
GameSystem gameSystem = GameSystem.builder()
.name(data.name())
.description(data.description())
.rulesMarkdown(data.rulesMarkdown())
.author(normalize(data.author()))
.isPublic(data.isPublic())
.build();
return gameSystemRepository.save(gameSystem);
}
public Optional<GameSystem> getGameSystemById(String id) {
return gameSystemRepository.findById(id);
}
public List<GameSystem> getAllGameSystems() {
return gameSystemRepository.findAll();
}
public GameSystem updateGameSystem(String id, GameSystemData data) {
GameSystem existing = gameSystemRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("GameSystem non trouvé avec l'ID: " + id));
existing.setName(data.name());
existing.setDescription(data.description());
existing.setRulesMarkdown(data.rulesMarkdown());
existing.setAuthor(normalize(data.author()));
existing.setPublic(data.isPublic());
return gameSystemRepository.save(existing);
}
public void deleteGameSystem(String id) {
gameSystemRepository.deleteById(id);
}
public boolean gameSystemExists(String id) {
return gameSystemRepository.existsById(id);
}
public List<GameSystem> searchGameSystems(String query) {
if (query == null || query.isBlank()) return List.of();
return gameSystemRepository.searchByName(query.trim());
}
private String normalize(String value) {
return (value == null || value.isBlank()) ? null : value;
}
}

View File

@@ -3,15 +3,18 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import org.springframework.stereotype.Component;
@@ -38,18 +41,24 @@ public class CampaignStructuralContextBuilder {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository;
public CampaignStructuralContextBuilder(
CampaignRepository campaignRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository) {
SceneRepository sceneRepository,
CharacterRepository characterRepository) {
this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository;
}
/** Longueur max du snippet de PJ injecté dans le contexte (coût tokens maîtrisé). */
private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
/**
* Construit la carte narrative d'une Campagne (arcs → chapitres → scènes,
* nom + description courte à chaque niveau).
@@ -65,13 +74,42 @@ public class CampaignStructuralContextBuilder {
.map(this::toArcSummary)
.collect(Collectors.toList());
List<CharacterSummary> characters = characterRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Character::getOrder))
.map(this::toCharacterSummary)
.collect(Collectors.toList());
return CampaignStructuralContext.builder()
.campaignName(campaign.getName())
.campaignDescription(campaign.getDescription())
.arcs(arcs)
.characters(characters)
.build();
}
/**
* Projette un PJ vers un résumé court : nom + 1re ligne "signifiante" du
* markdown (ni vide, ni un titre). Permet à l'IA de savoir "qui est Thorin"
* sans injecter toute sa fiche.
*/
private CharacterSummary toCharacterSummary(Character c) {
return CharacterSummary.builder()
.name(c.getName())
.snippet(extractSnippet(c.getMarkdownContent()))
.build();
}
private static String extractSnippet(String markdown) {
if (markdown == null || markdown.isBlank()) return "";
String firstLine = markdown.lines()
.map(String::strip)
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
.findFirst()
.orElse("");
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "";
}
private ArcSummary toArcSummary(Arc arc) {
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
.sorted(Comparator.comparingInt(Chapter::getOrder))

View File

@@ -2,9 +2,11 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import org.springframework.stereotype.Component;
@@ -26,20 +28,23 @@ public class NarrativeEntityContextBuilder {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository;
public NarrativeEntityContextBuilder(
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository) {
SceneRepository sceneRepository,
CharacterRepository characterRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository;
}
/**
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
*
* @param entityType "arc", "chapter" ou "scene" (insensible à la casse)
* @param entityType "arc", "chapter", "scene" ou "character" (insensible à la casse)
* @param entityId l'ID de l'entité
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
*/
@@ -49,6 +54,7 @@ public class NarrativeEntityContextBuilder {
case "arc" -> fromArc(loadArc(entityId));
case "chapter" -> fromChapter(loadChapter(entityId));
case "scene" -> fromScene(loadScene(entityId));
case "character" -> fromCharacter(loadCharacter(entityId));
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
};
}
@@ -70,6 +76,11 @@ public class NarrativeEntityContextBuilder {
.orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id));
}
private Character loadCharacter(String id) {
return characterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Personnage non trouvé: " + id));
}
// --- Mapping entité → VO ------------------------------------------------
private NarrativeEntityContext fromArc(Arc a) {
@@ -118,6 +129,16 @@ public class NarrativeEntityContextBuilder {
.build();
}
private NarrativeEntityContext fromCharacter(Character c) {
Map<String, String> fields = new LinkedHashMap<>();
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
return NarrativeEntityContext.builder()
.entityType("character")
.title(c.getName())
.fields(fields)
.build();
}
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */
private static void putField(Map<String, String> target, String key, String value) {
target.put(key, value == null ? "" : value);

View File

@@ -1,11 +1,14 @@
package com.loremind.application.generationcontext;
import com.loremind.application.gamesystemcontext.GameSystemContextBuilder;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.gamesystemcontext.GenerationIntent;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
@@ -34,6 +37,7 @@ public class StreamChatForCampaignUseCase {
private final CampaignStructuralContextBuilder campaignContextBuilder;
private final LoreStructuralContextBuilder loreContextBuilder;
private final NarrativeEntityContextBuilder narrativeEntityContextBuilder;
private final GameSystemContextBuilder gameSystemContextBuilder;
private final AiChatProvider aiChatProvider;
public StreamChatForCampaignUseCase(
@@ -41,11 +45,13 @@ public class StreamChatForCampaignUseCase {
CampaignStructuralContextBuilder campaignContextBuilder,
LoreStructuralContextBuilder loreContextBuilder,
NarrativeEntityContextBuilder narrativeEntityContextBuilder,
GameSystemContextBuilder gameSystemContextBuilder,
AiChatProvider aiChatProvider) {
this.campaignRepository = campaignRepository;
this.campaignContextBuilder = campaignContextBuilder;
this.loreContextBuilder = loreContextBuilder;
this.narrativeEntityContextBuilder = narrativeEntityContextBuilder;
this.gameSystemContextBuilder = gameSystemContextBuilder;
this.aiChatProvider = aiChatProvider;
}
@@ -78,12 +84,14 @@ public class StreamChatForCampaignUseCase {
CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaignId);
LoreStructuralContext loreContext = loadLinkedLoreContextOrNull(campaign);
NarrativeEntityContext narrativeEntity = buildNarrativeEntityOrNull(entityType, entityId);
GameSystemContext gameSystemContext = loadGameSystemContextOrNull(campaign, entityType);
ChatRequest request = ChatRequest.builder()
.messages(messages)
.loreContext(loreContext)
.campaignContext(campaignContext)
.narrativeEntity(narrativeEntity)
.gameSystemContext(gameSystemContext)
.build();
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
@@ -104,4 +112,16 @@ public class StreamChatForCampaignUseCase {
if (entityId == null || entityId.isBlank()) return null;
return narrativeEntityContextBuilder.build(entityType, entityId);
}
/**
* Charge le GameSystemContext si la campagne est liée à un GameSystem.
* L'entityType détermine quelles sections de règles sont injectées
* (SCENE → combat/PNJ, CHAPTER → combat/classes, ARC → lore/factions, autre → toutes).
* Retourne null en cas de GameSystem introuvable (dégradation gracieuse).
*/
private GameSystemContext loadGameSystemContextOrNull(Campaign campaign, String entityType) {
if (!campaign.isLinkedToGameSystem()) return null;
GenerationIntent intent = GenerationIntent.fromNarrativeEntityType(entityType);
return gameSystemContextBuilder.buildOptional(campaign.getGameSystemId(), intent).orElse(null);
}
}

View File

@@ -1,9 +1,13 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -16,11 +20,20 @@ import java.util.Optional;
public class LoreNodeService {
private final LoreNodeRepository loreNodeRepository;
private final PageRepository pageRepository;
public LoreNodeService(LoreNodeRepository loreNodeRepository) {
public LoreNodeService(LoreNodeRepository loreNodeRepository, PageRepository pageRepository) {
this.loreNodeRepository = loreNodeRepository;
this.pageRepository = pageRepository;
}
/**
* Compte des entités qui seront supprimées en cascade si le dossier est effacé :
* le dossier lui-même n'est pas compté, seuls les descendants (sous-dossiers
* récursifs + pages de l'ensemble du sous-arbre).
*/
public record DeletionImpact(int folders, int pages) {}
/**
* Crée un LoreNode (dossier) à partir d'un "objet changes" porteur des valeurs
* souhaitées (pattern Parameter Object) : évite les signatures qui gonflent
@@ -68,7 +81,64 @@ public class LoreNodeService {
return loreNodeRepository.save(existing);
}
/**
* Calcule l'impact d'une suppression en cascade : nombre de sous-dossiers
* (récursif, sans compter la racine) et de pages dans l'ensemble du sous-arbre.
*/
public DeletionImpact getDeletionImpact(String id) {
List<LoreNode> descendants = collectDescendants(id);
int pageTotal = pageRepository.findByNodeId(id).size();
for (LoreNode descendant : descendants) {
pageTotal += pageRepository.findByNodeId(descendant.getId()).size();
}
return new DeletionImpact(descendants.size(), pageTotal);
}
/**
* Supprime le dossier et tout son sous-arbre (sous-dossiers récursifs + pages).
* Suppression en profondeur d'abord (feuilles → racine) pour limiter les
* références orphelines en cours de transaction. Les FKs applicatives n'ayant
* pas de CASCADE en DB, on orchestre la descente ici.
*/
@Transactional
public void deleteLoreNode(String id) {
List<LoreNode> descendants = collectDescendants(id);
// Descendants retournés en ordre BFS (haut → bas) : on inverse pour
// supprimer les feuilles en premier, puis on finit par la racine.
for (int i = descendants.size() - 1; i >= 0; i--) {
String descendantId = descendants.get(i).getId();
deletePagesOfNode(descendantId);
loreNodeRepository.deleteById(descendantId);
}
deletePagesOfNode(id);
loreNodeRepository.deleteById(id);
}
private void deletePagesOfNode(String nodeId) {
for (Page page : pageRepository.findByNodeId(nodeId)) {
pageRepository.deleteById(page.getId());
}
}
/**
* Retourne tous les descendants (hors racine) d'un dossier, en ordre BFS.
* Parcours itératif pour éviter tout risque de débordement de pile sur
* une arborescence profonde malicieuse.
*/
private List<LoreNode> collectDescendants(String rootId) {
List<LoreNode> result = new ArrayList<>();
List<String> frontier = new ArrayList<>();
frontier.add(rootId);
while (!frontier.isEmpty()) {
List<String> nextFrontier = new ArrayList<>();
for (String parentId : frontier) {
for (LoreNode child : loreNodeRepository.findByParentId(parentId)) {
result.add(child);
nextFrontier.add(child.getId());
}
}
frontier = nextFrontier;
}
return result;
}
}

View File

@@ -1,10 +1,17 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@@ -26,15 +33,28 @@ public class LoreService {
private final LoreRepository loreRepository;
private final LoreNodeRepository loreNodeRepository;
private final PageRepository pageRepository;
private final TemplateRepository templateRepository;
private final CampaignRepository campaignRepository;
public LoreService(LoreRepository loreRepository,
LoreNodeRepository loreNodeRepository,
PageRepository pageRepository) {
PageRepository pageRepository,
TemplateRepository templateRepository,
CampaignRepository campaignRepository) {
this.loreRepository = loreRepository;
this.loreNodeRepository = loreNodeRepository;
this.pageRepository = pageRepository;
this.templateRepository = templateRepository;
this.campaignRepository = campaignRepository;
}
/**
* Compte des entités qui seront supprimées / détachées en cascade si le Lore
* est effacé. `detachedCampaigns` : campagnes qui perdront leur référence à
* ce Lore (leur loreId sera nullé) mais resteront présentes.
*/
public record DeletionImpact(int folders, int pages, int templates, int detachedCampaigns) {}
public Lore createLore(String name, String description) {
Lore lore = Lore.builder()
.name(name)
@@ -76,14 +96,61 @@ public class LoreService {
if (existingLore.isEmpty()) {
throw new IllegalArgumentException("Lore non trouvé avec l'ID: " + id);
}
Lore lore = existingLore.get();
lore.setName(name);
lore.setDescription(description);
return loreRepository.save(lore);
}
/**
* Calcule l'impact d'une suppression de Lore en cascade : dossiers + pages
* + templates supprimés, et campagnes qui seront détachées (loreId → null
* sans être supprimées, car une campagne peut vivre sans univers).
*/
public DeletionImpact getDeletionImpact(String id) {
int folders = (int) loreNodeRepository.countByLoreId(id);
int pages = (int) pageRepository.countByLoreId(id);
int templates = templateRepository.findByLoreId(id).size();
int detached = countCampaignsReferencingLore(id);
return new DeletionImpact(folders, pages, templates, detached);
}
/**
* Supprime le Lore et toutes ses entités dépendantes (dossiers, pages, templates).
* Les campagnes qui référençaient ce Lore sont conservées — leur loreId est
* mis à null (une campagne peut légitimement exister sans univers associé).
* Opération transactionnelle : atomique.
*/
@Transactional
public void deleteLore(String id) {
// Pages d'abord : elles référencent nodeId ET loreId, on les supprime
// globalement via loreId pour éviter d'en rater une rattachée à un
// node orphelin (ne devrait pas arriver, mais ceinture+bretelles).
for (Page page : pageRepository.findByLoreId(id)) {
pageRepository.deleteById(page.getId());
}
for (LoreNode node : loreNodeRepository.findByLoreId(id)) {
loreNodeRepository.deleteById(node.getId());
}
for (Template template : templateRepository.findByLoreId(id)) {
templateRepository.deleteById(template.getId());
}
// Détache les campagnes : on garde la campagne, on nulle juste la référence.
for (Campaign campaign : campaignRepository.findAll()) {
if (id.equals(campaign.getLoreId())) {
campaign.setLoreId(null);
campaignRepository.save(campaign);
}
}
loreRepository.deleteById(id);
}
private int countCampaignsReferencingLore(String id) {
int count = 0;
for (Campaign campaign : campaignRepository.findAll()) {
if (id.equals(campaign.getLoreId())) count++;
}
return count;
}
}

View File

@@ -28,7 +28,18 @@ public class Campaign {
*/
private String loreId;
/**
* Référence faible (weak reference) vers un GameSystem.
* Nullable : une campagne peut être "générique" (pas de système de JDR déclaré).
* Weak reference pour respecter la séparation des Bounded Contexts.
*/
private String gameSystemId;
public boolean isLinkedToLore() {
return this.loreId != null && !this.loreId.isBlank();
}
public boolean isLinkedToGameSystem() {
return this.gameSystemId != null && !this.gameSystemId.isBlank();
}
}

View File

@@ -0,0 +1,40 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Fiche de personnage joueur (PJ) d'une campagne.
* <p>
* MVP : contenu markdown libre, l'utilisateur met ce qu'il veut (stats,
* backstory, équipement). Évolution prévue vers un système templaté par
* GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D).
* <p>
* Scope strict PJ : les PNJ restent dans le Lore (pages templatées) ou
* dans les scènes elles-mêmes. Si le besoin de PNJ spécifiques à une
* campagne remonte, on étendra l'entité (ex: type enum PJ/PNJ).
*/
@Data
@Builder
public class Character {
private String id;
private String name;
/**
* Contenu libre en markdown — stats + backstory + notes. Nullable à la création,
* renseigné progressivement par le MJ.
*/
private String markdownContent;
/** Référence vers la Campaign parente. */
private String campaignId;
/** Ordre d'affichage dans la liste des PJ de la campagne. */
private int order;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Character;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des fiches de personnages (PJ).
*/
public interface CharacterRepository {
Character save(Character character);
Optional<Character> findById(String id);
List<Character> findByCampaignId(String campaignId);
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -37,7 +37,7 @@ public class Conversation {
/**
* Type d'entite focus, null si la conversation est ancree au niveau
* Lore/Campagne racine (pas sur une page/scene precise).
* Valeurs : "page", "arc", "chapter", "scene".
* Valeurs : "page", "arc", "chapter", "scene", "character".
*/
private String entityType;
private String entityId;

View File

@@ -0,0 +1,38 @@
package com.loremind.domain.gamesystemcontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Entité de domaine représentant un GameSystem (système de JDR).
* <p>
* Porte les règles d'un système (D&D, Nimble, Pathfinder, homebrew...) sous forme
* d'un markdown monolithique structuré par titres H2. Les sections sont extraites
* à la volée lors de l'injection dans les prompts IA (cf. GameSystemContextSelector).
* <p>
* {@code author} et {@code isPublic} sont des champs pensés pour un futur marketplace
* de rulesets partagés — non exploités au MVP mais persistés dès maintenant pour
* éviter une migration ultérieure.
*/
@Data
@Builder
public class GameSystem {
private String id;
private String name;
private String description;
/** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */
private String rulesMarkdown;
/** Auteur déclaré — futur marketplace. Nullable. */
private String author;
/** Flag de partage — futur marketplace. False par défaut. */
private boolean isPublic;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,57 @@
package com.loremind.domain.gamesystemcontext;
import java.util.Set;
/**
* Intent de génération utilisé pour sélectionner les sections d'un GameSystem
* à injecter dans le prompt IA.
* <p>
* Chaque intent porte une liste d'alias (case-insensitive, comparaison par
* {@code contains}) utilisée pour matcher les titres H2 du markdown de règles.
* <p>
* MVP : mapping codé en dur. Évoluera vers un mapping configurable par
* l'utilisateur dans l'éditeur de GameSystem (futur marketplace).
*/
public enum GenerationIntent {
/** Scène (combat / rencontre) : règles de résolution + format de stat block. */
SCENE(Set.of("combat", "monstre", "monster")),
/** Chapitre (segment narratif) : règles de combat + archétypes pour PNJ. */
CHAPTER(Set.of("combat", "classe", "class")),
/** Arc (structure narrative longue) : pas de règles spécifiques — toutes. */
ARC(Set.of()),
/** Fallback : toutes les sections (intent inconnu). */
GENERIC(Set.of());
private final Set<String> sectionAliases;
GenerationIntent(Set<String> sectionAliases) {
this.sectionAliases = sectionAliases;
}
public Set<String> getSectionAliases() {
return sectionAliases;
}
/** True si l'intent veut toutes les sections (pas de filtre). */
public boolean matchesAllSections() {
return sectionAliases.isEmpty();
}
/**
* Mappe un entityType de NarrativeEntityContext ("arc"/"chapter"/"scene")
* vers l'intent correspondant. Tout le reste (null, inconnu) tombe sur GENERIC.
*/
public static GenerationIntent fromNarrativeEntityType(String entityType) {
if (entityType == null) return GENERIC;
return switch (entityType.toLowerCase()) {
case "scene" -> SCENE;
case "chapter" -> CHAPTER;
case "arc" -> ARC;
default -> GENERIC;
};
}
}

View File

@@ -0,0 +1,24 @@
package com.loremind.domain.gamesystemcontext.ports;
import com.loremind.domain.gamesystemcontext.GameSystem;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des GameSystems.
*/
public interface GameSystemRepository {
GameSystem save(GameSystem gameSystem);
Optional<GameSystem> findById(String id);
List<GameSystem> findAll();
void deleteById(String id);
boolean existsById(String id);
List<GameSystem> searchByName(String query);
}

View File

@@ -30,6 +30,22 @@ public class CampaignStructuralContext {
String campaignName;
String campaignDescription;
@Singular List<ArcSummary> arcs;
/** Personnages joueurs (PJ) de la campagne. Vide si aucun. */
@Singular List<CharacterSummary> characters;
/**
* Résumé d'un PJ : nom + snippet court du markdown.
* Pas le markdown complet pour maîtriser le coût token (chaque campagne
* peut avoir 4-6 PJ × potentiellement 1-2k tokens/fiche = trop lourd).
* La fiche complète n'est injectée que si le PJ est l'entité focus
* (via NarrativeEntityContext, entity_type="character").
*/
@Value
@Builder
public static class CharacterSummary {
String name;
String snippet;
}
/** Résumé d'un arc : nom + description courte + ses chapitres. */
@Value

View File

@@ -39,4 +39,10 @@ public class ChatRequest {
/** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */
NarrativeEntityContext narrativeEntity;
/**
* Optionnel : règles du système de JDR de la campagne (filtrées par intent).
* Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP.
*/
GameSystemContext gameSystemContext;
}

View File

@@ -0,0 +1,30 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.Map;
/**
* Value Object représentant les règles de JDR injectées dans un prompt IA.
* <p>
* Contient uniquement les sections pertinentes pour l'intent de génération
* en cours (sélection effectuée par GameSystemContextBuilder). Les sections
* sont indexées par leur titre H2 original (ex : "Combat", "Classes").
*/
@Value
@Builder
public class GameSystemContext {
/** Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD"). */
String systemName;
/** Description courte du système (nullable). */
String systemDescription;
/**
* Sections de règles pertinentes, indexées par titre H2.
* Vide si le GameSystem n'a aucune règle ou si aucune section ne matche l'intent.
*/
Map<String, String> sections;
}

View File

@@ -1,17 +1,7 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.generationcontext.ports.AiProviderException;
import org.springframework.beans.factory.annotation.Value;
@@ -23,25 +13,21 @@ import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* Adapter de sortie (Architecture Hexagonale) : implémente AiChatProvider
* en appelant le Brain Python via WebClient + SSE (Server-Sent Events).
* <p>
* Responsabilités :
* 1. Traduire ChatRequest (domaine) -> JSON attendu par /chat/stream.
* Sérialise lore_context, page_context, campaign_context et
* narrative_entity de façon conditionnelle selon le scénario d'appel
* (chat Lore / chat Lore focalisé page / chat Campagne / chat Campagne
* focalisé arc-chapter-scene).
* 2. Consommer le flux SSE token par token.
* 3. Invoquer onToken / onComplete / onError au bon moment.
* 4. Traduire toute erreur technique en AiProviderException.
* Responsabilités (après extraction) :
* 1. Transport HTTP + consommation du flux SSE.
* 2. Dispatch des évènements SSE (data / done / error / usage).
* 3. Traduction des erreurs techniques en AiProviderException.
* <p>
* Les responsabilités auxiliaires sont déléguées :
* - Construction du payload JSON : {@link BrainChatPayloadBuilder}.
* - Parsing des payloads SSE : {@link BrainSseParser}.
* <p>
* Le domaine ne voit JAMAIS WebClient, Flux, ni la moindre URL.
*/
@@ -53,11 +39,17 @@ public class BrainAiChatClient implements AiChatProvider {
new ParameterizedTypeReference<>() {};
private final WebClient webClient;
private final BrainChatPayloadBuilder payloadBuilder;
private final BrainSseParser sseParser;
public BrainAiChatClient(
WebClient.Builder builder,
@Value("${brain.base-url}") String baseUrl) {
@Value("${brain.base-url}") String baseUrl,
BrainChatPayloadBuilder payloadBuilder,
BrainSseParser sseParser) {
this.webClient = builder.baseUrl(baseUrl).build();
this.payloadBuilder = payloadBuilder;
this.sseParser = sseParser;
}
@Override
@@ -68,7 +60,7 @@ public class BrainAiChatClient implements AiChatProvider {
Runnable onComplete,
Consumer<Throwable> onError) {
Map<String, Object> payload = toPayload(request);
Map<String, Object> payload = payloadBuilder.build(request);
Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(CHAT_STREAM_PATH)
@@ -92,13 +84,13 @@ public class BrainAiChatClient implements AiChatProvider {
}
}
/** Dispatch selon le type d'événement SSE (data par défaut, done, error, usage). */
/** Dispatch selon le type d'évènement SSE (data par défaut, done, error, usage). */
private void handleEvent(
ServerSentEvent<String> sse,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Consumer<Throwable> onError) {
String event = sse.event(); // null si pas d'event: xxx -> c'est un data par défaut
String event = sse.event(); // null si pas d'event: xxx -> data par défaut
String data = sse.data();
if ("error".equals(event)) {
@@ -107,235 +99,17 @@ public class BrainAiChatClient implements AiChatProvider {
return;
}
if ("done".equals(event)) {
return; // la fin est gérée par blockLast + onComplete
return; // fin gérée par blockLast + onComplete
}
if ("usage".equals(event)) {
ChatUsage usage = extractUsage(data);
ChatUsage usage = sseParser.parseUsage(data);
if (usage != null) onUsage.accept(usage);
return;
}
// Défaut : événement data avec JSON {"token":"..."}.
String token = extractToken(data);
// Défaut : évènement data avec JSON {"token":"..."}.
String token = sseParser.parseToken(data);
if (token != null && !token.isEmpty()) {
onToken.accept(token);
}
}
/**
* Parse un JSON {"system":N,"history":N,"current":N,"max":N} en ChatUsage.
* Renvoie null si le payload est illisible — dans ce cas on ne propage
* simplement pas d'usage, le stream token continue normalement.
*/
private ChatUsage extractUsage(String json) {
if (json == null) return null;
try {
int system = extractIntField(json, "system");
int history = extractIntField(json, "history");
int current = extractIntField(json, "current");
int max = extractIntField(json, "max");
return new ChatUsage(system, history, current, max);
} catch (Exception e) {
return null;
}
}
/** Parse minimaliste d'un champ entier JSON sans dépendre de Jackson. */
private int extractIntField(String json, String field) {
String needle = "\"" + field + "\"";
int idx = json.indexOf(needle);
if (idx < 0) return 0;
int colon = json.indexOf(':', idx);
if (colon < 0) return 0;
int start = colon + 1;
while (start < json.length() && Character.isWhitespace(json.charAt(start))) start++;
int end = start;
while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '-')) end++;
if (end == start) return 0;
return Integer.parseInt(json.substring(start, end));
}
/**
* Parse minimaliste du JSON {"token":"..."} sans pull Jackson ici.
* Si le format se complexifie, on remplacera par un DTO Jackson.
*/
private String extractToken(String json) {
if (json == null) return null;
int idx = json.indexOf("\"token\"");
if (idx < 0) return null;
int colon = json.indexOf(':', idx);
int firstQuote = json.indexOf('"', colon + 1);
int lastQuote = json.lastIndexOf('"');
if (firstQuote < 0 || lastQuote <= firstQuote) return null;
return json.substring(firstQuote + 1, lastQuote)
.replace("\\n", "\n")
.replace("\\\"", "\"")
.replace("\\\\", "\\");
}
// --- Construction du payload JSON vers le Brain -------------------------
/**
* Construit le payload JSON. Chaque contexte optionnel est omis s'il est
* null, pour s'aligner sur le schéma Pydantic côté Brain (champs
* Optional qui restent absents du dict transmis au LLM).
*/
private Map<String, Object> toPayload(ChatRequest request) {
Map<String, Object> root = new LinkedHashMap<>();
root.put("messages", request.getMessages().stream()
.map(this::messageToMap)
.collect(Collectors.toList()));
if (request.getLoreContext() != null) {
root.put("lore_context", loreContextToMap(request.getLoreContext()));
}
if (request.getPageContext() != null) {
root.put("page_context", pageContextToMap(request.getPageContext()));
}
if (request.getCampaignContext() != null) {
root.put("campaign_context", campaignContextToMap(request.getCampaignContext()));
}
if (request.getNarrativeEntity() != null) {
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
}
return root;
}
private Map<String, Object> messageToMap(ChatMessage m) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("role", m.role());
map.put("content", m.content());
return map;
}
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("lore_name", ctx.getLoreName());
map.put("lore_description", ctx.getLoreDescription());
Map<String, Object> foldersMap = new LinkedHashMap<>();
for (Map.Entry<String, List<PageSummary>> e : ctx.getFolders().entrySet()) {
foldersMap.put(e.getKey(), e.getValue().stream()
.map(this::pageSummaryToMap)
.collect(Collectors.toList()));
}
map.put("folders", foldersMap);
map.put("tags", ctx.getTags());
return map;
}
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", ps.getTitle());
map.put("template_name", ps.getTemplateName());
// values/tags/related_page_titles ne sont sérialisés que s'ils contiennent
// de l'info — payload réseau plus léger quand la page est vierge.
if (ps.getValues() != null && !ps.getValues().isEmpty()) {
map.put("values", ps.getValues());
}
if (ps.getTags() != null && !ps.getTags().isEmpty()) {
map.put("tags", ps.getTags());
}
if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) {
map.put("related_page_titles", ps.getRelatedPageTitles());
}
return map;
}
private Map<String, Object> pageContextToMap(PageContext pc) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", pc.getTitle());
map.put("template_name", pc.getTemplateName());
map.put("template_fields", pc.getTemplateFields());
map.put("values", pc.getValues());
return map;
}
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("campaign_name", ctx.getCampaignName());
map.put("campaign_description", ctx.getCampaignDescription());
map.put("arcs", ctx.getArcs().stream()
.map(this::arcSummaryToMap)
.collect(Collectors.toList()));
return map;
}
/**
* Helper generic pour serialiser les entites structurelles (Arc/Chapter/Scene)
* avec name, description et illustration_count conditionnel.
*/
private <T> Map<String, Object> structuralSummaryToMap(
T entity,
java.util.function.Function<T, String> nameExtractor,
java.util.function.Function<T, String> descriptionExtractor,
java.util.function.Function<T, Integer> illustrationCountExtractor,
java.util.function.BiConsumer<Map<String, Object>, T> childSerializer) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", nameExtractor.apply(entity));
map.put("description", descriptionExtractor.apply(entity));
// Envoye au Python pour enrichir le prompt ("N illustrations attachees").
// Serialise uniquement si > 0 pour economiser le payload sur les entites sans images.
if (illustrationCountExtractor.apply(entity) > 0) {
map.put("illustration_count", illustrationCountExtractor.apply(entity));
}
childSerializer.accept(map, entity);
return map;
}
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
return structuralSummaryToMap(
a,
ArcSummary::getName,
ArcSummary::getDescription,
ArcSummary::getIllustrationCount,
(map, arc) -> map.put("chapters", arc.getChapters().stream()
.map(this::chapterSummaryToMap)
.collect(Collectors.toList())));
}
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
return structuralSummaryToMap(
c,
ChapterSummary::getName,
ChapterSummary::getDescription,
ChapterSummary::getIllustrationCount,
(map, chapter) -> map.put("scenes", chapter.getScenes().stream()
.map(this::sceneSummaryToMap)
.collect(Collectors.toList())));
}
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
return structuralSummaryToMap(
s,
SceneSummary::getName,
SceneSummary::getDescription,
SceneSummary::getIllustrationCount,
(map, scene) -> {
// Branches narratives : serialise uniquement si presentes, pour garder
// un payload leger sur les scenes lineaires classiques.
if (s.getBranches() != null && !s.getBranches().isEmpty()) {
map.put("branches", s.getBranches().stream()
.map(this::branchHintToMap)
.collect(Collectors.toList()));
}
});
}
private Map<String, Object> branchHintToMap(BranchHint b) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("label", b.getLabel());
map.put("target_scene_name", b.getTargetSceneName());
if (b.getCondition() != null && !b.getCondition().isBlank()) {
map.put("condition", b.getCondition());
}
return map;
}
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("entity_type", ne.getEntityType());
map.put("title", ne.getTitle());
map.put("fields", ne.getFields());
return map;
}
}

View File

@@ -0,0 +1,222 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.PageContext;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Helper d'infrastructure : traduit un ChatRequest (domaine) vers le dict JSON
* attendu par le Brain Python (/chat/stream).
* <p>
* Extrait de BrainAiChatClient pour isoler la responsabilité "sérialisation
* de payload" (SRP) — le client HTTP se concentre désormais uniquement sur le
* transport et le streaming SSE.
* <p>
* Chaque contexte optionnel (lore, page, campaign, entité narrative) est omis
* si null, pour s'aligner sur le schéma Pydantic (champs Optional absents).
*/
@Component
public class BrainChatPayloadBuilder {
public Map<String, Object> build(ChatRequest request) {
Map<String, Object> root = new LinkedHashMap<>();
root.put("messages", request.getMessages().stream()
.map(this::messageToMap)
.collect(Collectors.toList()));
if (request.getLoreContext() != null) {
root.put("lore_context", loreContextToMap(request.getLoreContext()));
}
if (request.getPageContext() != null) {
root.put("page_context", pageContextToMap(request.getPageContext()));
}
if (request.getCampaignContext() != null) {
root.put("campaign_context", campaignContextToMap(request.getCampaignContext()));
}
if (request.getNarrativeEntity() != null) {
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
}
if (request.getGameSystemContext() != null) {
root.put("game_system_context", gameSystemContextToMap(request.getGameSystemContext()));
}
return root;
}
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("system_name", gs.getSystemName());
if (gs.getSystemDescription() != null && !gs.getSystemDescription().isBlank()) {
map.put("system_description", gs.getSystemDescription());
}
map.put("sections", gs.getSections() != null ? gs.getSections() : Map.of());
return map;
}
private Map<String, Object> messageToMap(ChatMessage m) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("role", m.role());
map.put("content", m.content());
return map;
}
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("lore_name", ctx.getLoreName());
map.put("lore_description", ctx.getLoreDescription());
Map<String, Object> foldersMap = new LinkedHashMap<>();
for (Map.Entry<String, List<PageSummary>> e : ctx.getFolders().entrySet()) {
foldersMap.put(e.getKey(), e.getValue().stream()
.map(this::pageSummaryToMap)
.collect(Collectors.toList()));
}
map.put("folders", foldersMap);
map.put("tags", ctx.getTags());
return map;
}
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", ps.getTitle());
map.put("template_name", ps.getTemplateName());
// values/tags/related_page_titles : omis si vides pour alléger le payload.
if (ps.getValues() != null && !ps.getValues().isEmpty()) {
map.put("values", ps.getValues());
}
if (ps.getTags() != null && !ps.getTags().isEmpty()) {
map.put("tags", ps.getTags());
}
if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) {
map.put("related_page_titles", ps.getRelatedPageTitles());
}
return map;
}
private Map<String, Object> pageContextToMap(PageContext pc) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", pc.getTitle());
map.put("template_name", pc.getTemplateName());
map.put("template_fields", pc.getTemplateFields());
map.put("values", pc.getValues());
return map;
}
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("campaign_name", ctx.getCampaignName());
map.put("campaign_description", ctx.getCampaignDescription());
map.put("arcs", ctx.getArcs().stream()
.map(this::arcSummaryToMap)
.collect(Collectors.toList()));
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
if (ctx.getCharacters() != null && !ctx.getCharacters().isEmpty()) {
map.put("characters", ctx.getCharacters().stream()
.map(this::characterSummaryToMap)
.collect(Collectors.toList()));
}
return map;
}
private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", c.getName());
if (c.getSnippet() != null && !c.getSnippet().isBlank()) {
map.put("snippet", c.getSnippet());
}
return map;
}
/**
* Helper générique pour sérialiser les entités structurelles (Arc/Chapter/Scene)
* avec name, description et illustration_count conditionnel.
*/
private <T> Map<String, Object> structuralSummaryToMap(
T entity,
Function<T, String> nameExtractor,
Function<T, String> descriptionExtractor,
Function<T, Integer> illustrationCountExtractor,
BiConsumer<Map<String, Object>, T> childSerializer) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", nameExtractor.apply(entity));
map.put("description", descriptionExtractor.apply(entity));
if (illustrationCountExtractor.apply(entity) > 0) {
map.put("illustration_count", illustrationCountExtractor.apply(entity));
}
childSerializer.accept(map, entity);
return map;
}
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
return structuralSummaryToMap(
a,
ArcSummary::getName,
ArcSummary::getDescription,
ArcSummary::getIllustrationCount,
(map, arc) -> map.put("chapters", arc.getChapters().stream()
.map(this::chapterSummaryToMap)
.collect(Collectors.toList())));
}
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
return structuralSummaryToMap(
c,
ChapterSummary::getName,
ChapterSummary::getDescription,
ChapterSummary::getIllustrationCount,
(map, chapter) -> map.put("scenes", chapter.getScenes().stream()
.map(this::sceneSummaryToMap)
.collect(Collectors.toList())));
}
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
return structuralSummaryToMap(
s,
SceneSummary::getName,
SceneSummary::getDescription,
SceneSummary::getIllustrationCount,
(map, scene) -> {
// Branches narratives : omises si absentes (scènes linéaires classiques).
if (s.getBranches() != null && !s.getBranches().isEmpty()) {
map.put("branches", s.getBranches().stream()
.map(this::branchHintToMap)
.collect(Collectors.toList()));
}
});
}
private Map<String, Object> branchHintToMap(BranchHint b) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("label", b.getLabel());
map.put("target_scene_name", b.getTargetSceneName());
if (b.getCondition() != null && !b.getCondition().isBlank()) {
map.put("condition", b.getCondition());
}
return map;
}
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("entity_type", ne.getEntityType());
map.put("title", ne.getTitle());
map.put("fields", ne.getFields());
return map;
}
}

View File

@@ -0,0 +1,67 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.generationcontext.ChatUsage;
import org.springframework.stereotype.Component;
/**
* Helper d'infrastructure : parse les payloads JSON véhiculés dans les
* évènements SSE reçus du Brain Python.
* <p>
* Implémentation volontairement minimaliste (pas de Jackson ici) car les
* schémas attendus sont figés et simples : {"token":"..."} et
* {"system":N,"history":N,"current":N,"max":N}. Si la complexité augmente,
* remplacer par un ObjectMapper + DTOs.
*/
@Component
public class BrainSseParser {
/**
* Parse un JSON {"system":N,"history":N,"current":N,"max":N} en ChatUsage.
* Renvoie null si le payload est illisible — l'appelant décidera de ne
* simplement pas propager l'usage (le stream token continue).
*/
public ChatUsage parseUsage(String json) {
if (json == null) return null;
try {
int system = extractIntField(json, "system");
int history = extractIntField(json, "history");
int current = extractIntField(json, "current");
int max = extractIntField(json, "max");
return new ChatUsage(system, history, current, max);
} catch (Exception e) {
return null;
}
}
/**
* Parse {"token":"..."} et renvoie la valeur du champ token (chaîne vide
* ou null si introuvable).
*/
public String parseToken(String json) {
if (json == null) return null;
int idx = json.indexOf("\"token\"");
if (idx < 0) return null;
int colon = json.indexOf(':', idx);
int firstQuote = json.indexOf('"', colon + 1);
int lastQuote = json.lastIndexOf('"');
if (firstQuote < 0 || lastQuote <= firstQuote) return null;
return json.substring(firstQuote + 1, lastQuote)
.replace("\\n", "\n")
.replace("\\\"", "\"")
.replace("\\\\", "\\");
}
private int extractIntField(String json, String field) {
String needle = "\"" + field + "\"";
int idx = json.indexOf(needle);
if (idx < 0) return 0;
int colon = json.indexOf(':', idx);
if (colon < 0) return 0;
int start = colon + 1;
while (start < json.length() && Character.isWhitespace(json.charAt(start))) start++;
int end = start;
while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '-')) end++;
if (end == start) return 0;
return Integer.parseInt(json.substring(start, end));
}
}

View File

@@ -0,0 +1,143 @@
package com.loremind.infrastructure.persistence;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Seed 3 rulesets libres au premier démarrage (si la table game_systems est vide).
* <p>
* Objectif : donner à l'utilisateur un point de départ pour comprendre le format
* attendu (markdown structuré par titres H2) et permettre une démo "out of the box"
* sans devoir taper ses propres règles.
* <p>
* Les rulesets fournis sont des <b>extraits libres</b> (Nimble, SRD 5.1 extrait,
* homebrew exemple) — pas des règles officielles complètes. L'utilisateur est
* libre de les éditer, supprimer, ou les utiliser comme template.
* <p>
* Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé,
* il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur).
*/
@Component
public class GameSystemSeeder {
private static final Logger log = LoggerFactory.getLogger(GameSystemSeeder.class);
private final GameSystemRepository gameSystemRepository;
public GameSystemSeeder(GameSystemRepository gameSystemRepository) {
this.gameSystemRepository = gameSystemRepository;
}
@EventListener(ApplicationReadyEvent.class)
public void seedIfEmpty() {
if (!gameSystemRepository.findAll().isEmpty()) {
log.debug("GameSystem seed skipped — table non vide.");
return;
}
log.info("Seed initial des GameSystems (table vide)...");
for (GameSystem gs : defaultSystems()) {
gameSystemRepository.save(gs);
}
log.info("GameSystems seedés : {}", defaultSystems().size());
}
private List<GameSystem> defaultSystems() {
return List.of(
GameSystem.builder()
.name("Nimble (extrait)")
.description("Système léger et narratif, résolution rapide des combats.")
.author("LoreMind seed")
.isPublic(false)
.rulesMarkdown(NIMBLE_RULES)
.build(),
GameSystem.builder()
.name("D&D 5e SRD (extrait)")
.description("Extrait libre des bases du System Reference Document 5.1.")
.author("LoreMind seed")
.isPublic(false)
.rulesMarkdown(DND_SRD_RULES)
.build(),
GameSystem.builder()
.name("Homebrew Exemple")
.description("Template minimaliste à dupliquer pour créer votre propre système.")
.author("LoreMind seed")
.isPublic(false)
.rulesMarkdown(HOMEBREW_EXAMPLE)
.build()
);
}
private static final String NIMBLE_RULES = """
Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).
## Combat
- Initiative libre : les joueurs décrivent leur action dans l'ordre qu'ils veulent, le MJ joue les ennemis quand la fiction l'exige.
- Résolution : 1d20 + mod, difficulté 10/15/20 (facile/normal/dur). 20 naturel = critique (double dégâts).
- Dégâts : arme légère 1d6, arme lourde 1d10, projectile 1d8. Pas de table d'armure, l'armure augmente la difficulté à toucher.
- Blessures : un PJ peut encaisser 3 blessures graves avant de tomber. Pas de PV fins — on raconte les coups.
## Classes
- **Guerrier** : +2 en combat, peut relancer un dé de dégât 1×/scène.
- **Explorateur** : +2 en perception/survie, ignore la première blessure d'une scène.
- **Mage** : peut lancer un effet de magie par scène, nécessite une composante racontée.
- **Barde** : +2 en social, peut inspirer un allié (relance de dé).
## Monstres
Les monstres ont 3 stats : Menace (difficulté à toucher), Dégâts (dé de dégât), Résistance (nombre de blessures).
Exemples : Gobelin (Menace 10, 1d6, 1), Ogre (Menace 13, 1d10, 3), Dragon adulte (Menace 18, 2d10, 6).
""";
private static final String DND_SRD_RULES = """
Extrait libre du SRD 5.1 (Open Game License). Pour les règles complètes, consulter le SRD officiel.
## Combat
- Initiative : 1d20 + mod Dex au début du combat, ordre fixe par round.
- Action par tour : une action, une action bonus (si classe le permet), une réaction, mouvement jusqu'à la vitesse.
- Attaque : 1d20 + mod caractéristique + bonus maîtrise vs CA de la cible.
- Dégâts : dé de l'arme + mod caractéristique. Critique sur 20 naturel (double les dés de dégâts).
- Avantage/Désavantage : lancer 2d20 et garder le meilleur / pire.
## Classes
- **Barbare** : d12 PV, rage (+dégâts, résistance). Caractéristique principale : Force.
- **Barde** : d8 PV, sorts + inspiration bardique. Caractéristique : Charisme.
- **Clerc** : d8 PV, sorts divins, canalise la divinité. Caractéristique : Sagesse.
- **Druide** : d8 PV, sorts nature + forme animale. Caractéristique : Sagesse.
- **Ensorceleur** : d6 PV, sorts innés + métamagie. Caractéristique : Charisme.
- **Guerrier** : d10 PV, maîtrise martiale, second souffle. Caractéristique : Force ou Dextérité.
- **Magicien** : d6 PV, livre de sorts, grande flexibilité. Caractéristique : Intelligence.
- **Moine** : d8 PV, arts martiaux + ki. Caractéristique : Dextérité + Sagesse.
- **Paladin** : d10 PV, sorts + serment + imposition des mains. Caractéristique : Force + Charisme.
- **Rôdeur** : d10 PV, ennemi juré + explorateur + sorts. Caractéristique : Dextérité + Sagesse.
- **Roublard** : d8 PV, attaque sournoise + expertise. Caractéristique : Dextérité.
## Monstres
Stat block standard : CA, PV, Vitesse, For/Dex/Con/Int/Sag/Cha, jets de sauvegarde, compétences, sens, langues, Facteur de Puissance (FP).
Exemples : Gobelin (FP 1/4, CA 15, 7 PV), Ogre (FP 2, CA 11, 59 PV), Dragon rouge adulte (FP 17, CA 19, 256 PV).
""";
private static final String HOMEBREW_EXAMPLE = """
Template vide à dupliquer et remplir pour créer votre propre système.
## Combat
(Décrivez ici comment se résout un combat : initiative, jet d'attaque, dégâts, points de vie, critiques...)
## Classes
(Listez les archétypes jouables : nom, stats de base, capacités signature.)
## Monstres
(Format de stat block pour vos créatures : stats, capacités spéciales, FP/niveau.)
## Magie
(Si votre système a un système de magie : écoles, coût, composantes, listes de sorts/pouvoirs.)
## Progression
(Comment les PJ montent en puissance : XP, niveaux, acquisitions par niveau.)
""";
}

View File

@@ -45,6 +45,13 @@ public class CampaignJpaEntity {
@Column(name = "lore_id")
private String loreId;
/**
* ID du GameSystem associé (nullable).
* Weak reference inter-contexte — pas de @ManyToOne / pas de FK DB.
*/
@Column(name = "game_system_id")
private String gameSystemId;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();

View File

@@ -0,0 +1,56 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Entité JPA pour les fiches de personnages (PJ) d'une campagne.
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte :
* on reste dans le Campaign Context, mais l'agrégat Character est autonome).
*/
@Entity
@Table(name = "characters")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CharacterJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(name = "markdown_content", columnDefinition = "TEXT")
private String markdownContent;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "\"order\"", nullable = false)
private int order;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,57 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Entité JPA pour la persistance des GameSystems (systèmes de JDR).
*/
@Entity
@Table(name = "game_systems")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GameSystemJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "rules_markdown", columnDefinition = "TEXT")
private String rulesMarkdown;
@Column
private String author;
@Column(name = "is_public", nullable = false)
private boolean isPublic;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,13 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> {
List<CharacterJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
}

View File

@@ -0,0 +1,16 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.GameSystemJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface GameSystemJpaRepository extends JpaRepository<GameSystemJpaEntity, Long> {
@Query("SELECT g FROM GameSystemJpaEntity g WHERE LOWER(g.name) LIKE LOWER(CONCAT('%', :query, '%'))")
List<GameSystemJpaEntity> findByNameContainingIgnoreCase(@Param("query") String query);
}

View File

@@ -71,6 +71,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
.updatedAt(jpaEntity.getUpdatedAt())
.arcsCount(jpaEntity.getArcsCount())
.loreId(jpaEntity.getLoreId())
.gameSystemId(jpaEntity.getGameSystemId())
.build();
}
@@ -84,6 +85,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
.updatedAt(campaign.getUpdatedAt())
.arcsCount(campaign.getArcsCount())
.loreId(campaign.getLoreId())
.gameSystemId(campaign.getGameSystemId())
.build();
}
}

View File

@@ -0,0 +1,75 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity;
import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class PostgresCharacterRepository implements CharacterRepository {
private final CharacterJpaRepository jpaRepository;
public PostgresCharacterRepository(CharacterJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Character save(Character character) {
CharacterJpaEntity entity = toJpaEntity(character);
CharacterJpaEntity saved = jpaRepository.save(entity);
return toDomainEntity(saved);
}
@Override
public Optional<Character> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
}
@Override
public List<Character> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public boolean existsById(String id) {
return jpaRepository.existsById(Long.parseLong(id));
}
private Character toDomainEntity(CharacterJpaEntity e) {
return Character.builder()
.id(e.getId().toString())
.name(e.getName())
.markdownContent(e.getMarkdownContent())
.campaignId(e.getCampaignId().toString())
.order(e.getOrder())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private CharacterJpaEntity toJpaEntity(Character c) {
Long id = c.getId() != null ? Long.parseLong(c.getId()) : null;
return CharacterJpaEntity.builder()
.id(id)
.name(c.getName())
.markdownContent(c.getMarkdownContent())
.campaignId(Long.parseLong(c.getCampaignId()))
.order(c.getOrder())
.createdAt(c.getCreatedAt())
.updatedAt(c.getUpdatedAt())
.build();
}
}

View File

@@ -0,0 +1,84 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.infrastructure.persistence.entity.GameSystemJpaEntity;
import com.loremind.infrastructure.persistence.jpa.GameSystemJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class PostgresGameSystemRepository implements GameSystemRepository {
private final GameSystemJpaRepository jpaRepository;
public PostgresGameSystemRepository(GameSystemJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public GameSystem save(GameSystem gameSystem) {
GameSystemJpaEntity entity = toJpaEntity(gameSystem);
GameSystemJpaEntity saved = jpaRepository.save(entity);
return toDomainEntity(saved);
}
@Override
public Optional<GameSystem> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
}
@Override
public List<GameSystem> findAll() {
return jpaRepository.findAll().stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public boolean existsById(String id) {
return jpaRepository.existsById(Long.parseLong(id));
}
@Override
public List<GameSystem> searchByName(String query) {
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
private GameSystem toDomainEntity(GameSystemJpaEntity e) {
return GameSystem.builder()
.id(e.getId().toString())
.name(e.getName())
.description(e.getDescription())
.rulesMarkdown(e.getRulesMarkdown())
.author(e.getAuthor())
.isPublic(e.isPublic())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private GameSystemJpaEntity toJpaEntity(GameSystem g) {
Long id = g.getId() != null ? Long.parseLong(g.getId()) : null;
return GameSystemJpaEntity.builder()
.id(id)
.name(g.getName())
.description(g.getDescription())
.rulesMarkdown(g.getRulesMarkdown())
.author(g.getAuthor())
.isPublic(g.isPublic())
.createdAt(g.getCreatedAt())
.updatedAt(g.getUpdatedAt())
.build();
}
}

View File

@@ -68,4 +68,12 @@ public class ArcController {
arcService.deleteArc(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<ArcService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (!arcService.arcExists(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(arcService.getDeletionImpact(id));
}
}

View File

@@ -31,7 +31,7 @@ public class CampaignController {
public ResponseEntity<CampaignDTO> createCampaign(@RequestBody CampaignDTO campaignDTO) {
Campaign campaign = campaignMapper.toDomain(campaignDTO);
Campaign createdCampaign = campaignService.createCampaign(
new CampaignService.CampaignData(campaign.getName(), campaign.getDescription(), campaign.getLoreId())
new CampaignService.CampaignData(campaign.getName(), campaign.getDescription(), campaign.getLoreId(), campaign.getGameSystemId())
);
return ResponseEntity.ok(campaignMapper.toDTO(createdCampaign));
}
@@ -64,7 +64,7 @@ public class CampaignController {
public ResponseEntity<CampaignDTO> updateCampaign(@PathVariable String id, @RequestBody CampaignDTO campaignDTO) {
Campaign updatedCampaign = campaignService.updateCampaign(
id,
new CampaignService.CampaignData(campaignDTO.getName(), campaignDTO.getDescription(), campaignDTO.getLoreId())
new CampaignService.CampaignData(campaignDTO.getName(), campaignDTO.getDescription(), campaignDTO.getLoreId(), campaignDTO.getGameSystemId())
);
return ResponseEntity.ok(campaignMapper.toDTO(updatedCampaign));
}
@@ -74,4 +74,16 @@ public class CampaignController {
campaignService.deleteCampaign(id);
return ResponseEntity.noContent().build();
}
/**
* Récapitulatif des entités qui seront supprimées en cascade : utilisé par
* l'UI pour afficher "X arcs, Y chapitres, Z scènes..." dans la confirmation.
*/
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<CampaignService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (!campaignService.campaignExists(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(campaignService.getDeletionImpact(id));
}
}

View File

@@ -68,4 +68,12 @@ public class ChapterController {
chapterService.deleteChapter(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<ChapterService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (!chapterService.chapterExists(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(chapterService.getDeletionImpact(id));
}
}

View File

@@ -0,0 +1,62 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.CharacterService;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO;
import com.loremind.infrastructure.web.mapper.CharacterMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/characters")
public class CharacterController {
private final CharacterService characterService;
private final CharacterMapper characterMapper;
public CharacterController(CharacterService characterService, CharacterMapper characterMapper) {
this.characterService = characterService;
this.characterMapper = characterMapper;
}
@PostMapping
public ResponseEntity<CharacterDTO> createCharacter(@RequestBody CharacterDTO dto) {
Character created = characterService.createCharacter(
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
);
return ResponseEntity.ok(characterMapper.toDTO(created));
}
@GetMapping("/{id}")
public ResponseEntity<CharacterDTO> getCharacterById(@PathVariable String id) {
return characterService.getCharacterById(id)
.map(c -> ResponseEntity.ok(characterMapper.toDTO(c)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<CharacterDTO>> getCharactersByCampaign(@PathVariable String campaignId) {
List<CharacterDTO> dtos = characterService.getCharactersByCampaignId(campaignId).stream()
.map(characterMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PutMapping("/{id}")
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
Character updated = characterService.updateCharacter(
id,
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
);
return ResponseEntity.ok(characterMapper.toDTO(updated));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteCharacter(@PathVariable String id) {
characterService.deleteCharacter(id);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,75 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.gamesystemcontext.GameSystemService;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/game-systems")
public class GameSystemController {
private final GameSystemService gameSystemService;
private final GameSystemMapper gameSystemMapper;
public GameSystemController(GameSystemService gameSystemService, GameSystemMapper gameSystemMapper) {
this.gameSystemService = gameSystemService;
this.gameSystemMapper = gameSystemMapper;
}
@PostMapping
public ResponseEntity<GameSystemDTO> createGameSystem(@RequestBody GameSystemDTO dto) {
GameSystem created = gameSystemService.createGameSystem(toData(dto));
return ResponseEntity.ok(gameSystemMapper.toDTO(created));
}
@GetMapping("/{id}")
public ResponseEntity<GameSystemDTO> getGameSystemById(@PathVariable String id) {
return gameSystemService.getGameSystemById(id)
.map(g -> ResponseEntity.ok(gameSystemMapper.toDTO(g)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping
public ResponseEntity<List<GameSystemDTO>> getAllGameSystems() {
List<GameSystemDTO> dtos = gameSystemService.getAllGameSystems().stream()
.map(gameSystemMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@GetMapping("/search")
public ResponseEntity<List<GameSystemDTO>> searchGameSystems(@RequestParam("q") String query) {
List<GameSystemDTO> dtos = gameSystemService.searchGameSystems(query).stream()
.map(gameSystemMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PutMapping("/{id}")
public ResponseEntity<GameSystemDTO> updateGameSystem(@PathVariable String id, @RequestBody GameSystemDTO dto) {
GameSystem updated = gameSystemService.updateGameSystem(id, toData(dto));
return ResponseEntity.ok(gameSystemMapper.toDTO(updated));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteGameSystem(@PathVariable String id) {
gameSystemService.deleteGameSystem(id);
return ResponseEntity.noContent().build();
}
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
return new GameSystemService.GameSystemData(
dto.getName(),
dto.getDescription(),
dto.getRulesMarkdown(),
dto.getAuthor(),
dto.isPublic()
);
}
}

View File

@@ -69,4 +69,17 @@ public class LoreController {
loreService.deleteLore(id);
return ResponseEntity.noContent().build();
}
/**
* Récapitulatif des entités qui seront supprimées / détachées en cascade.
* Utilisé par l'UI pour afficher "X dossiers, Y pages, Z templates,
* N campagne(s) détachée(s)" dans la confirmation.
*/
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<LoreService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (loreService.getLoreById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(loreService.getDeletionImpact(id));
}
}

View File

@@ -97,4 +97,16 @@ public class LoreNodeController {
loreNodeService.deleteLoreNode(id);
return ResponseEntity.noContent().build();
}
/**
* Récapitulatif des entités qui seront supprimées en cascade : utilisé par
* l'UI pour afficher "X sous-dossiers, Y pages..." dans la confirmation.
*/
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<LoreNodeService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (loreNodeService.getLoreNodeById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(loreNodeService.getDeletionImpact(id));
}
}

View File

@@ -15,4 +15,6 @@ public class CampaignDTO {
private int arcsCount;
/** Nullable : campagne sans univers associé. */
private String loreId;
/** Nullable : campagne sans système de JDR associé (générique). */
private String gameSystemId;
}

View File

@@ -0,0 +1,16 @@
package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.Data;
/**
* DTO pour les fiches de personnages (PJ) d'une campagne.
*/
@Data
public class CharacterDTO {
private String id;
private String name;
private String markdownContent;
private String campaignId;
private int order;
}

View File

@@ -0,0 +1,17 @@
package com.loremind.infrastructure.web.dto.gamesystemcontext;
import lombok.Data;
/**
* DTO pour l'entité GameSystem (système de JDR).
*/
@Data
public class GameSystemDTO {
private String id;
private String name;
private String description;
private String rulesMarkdown;
private String author;
private boolean isPublic;
}

View File

@@ -21,6 +21,7 @@ public class CampaignMapper {
dto.setDescription(campaign.getDescription());
dto.setArcsCount(campaign.getArcsCount());
dto.setLoreId(campaign.getLoreId());
dto.setGameSystemId(campaign.getGameSystemId());
return dto;
}
@@ -35,6 +36,7 @@ public class CampaignMapper {
.description(dto.getDescription())
.arcsCount(dto.getArcsCount())
.loreId(dto.getLoreId())
.gameSystemId(dto.getGameSystemId())
.build();
}
}

View File

@@ -0,0 +1,31 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO;
import org.springframework.stereotype.Component;
@Component
public class CharacterMapper {
public CharacterDTO toDTO(Character c) {
if (c == null) return null;
CharacterDTO dto = new CharacterDTO();
dto.setId(c.getId());
dto.setName(c.getName());
dto.setMarkdownContent(c.getMarkdownContent());
dto.setCampaignId(c.getCampaignId());
dto.setOrder(c.getOrder());
return dto;
}
public Character toDomain(CharacterDTO dto) {
if (dto == null) return null;
return Character.builder()
.id(dto.getId())
.name(dto.getName())
.markdownContent(dto.getMarkdownContent())
.campaignId(dto.getCampaignId())
.order(dto.getOrder())
.build();
}
}

View File

@@ -0,0 +1,33 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import org.springframework.stereotype.Component;
@Component
public class GameSystemMapper {
public GameSystemDTO toDTO(GameSystem g) {
if (g == null) return null;
GameSystemDTO dto = new GameSystemDTO();
dto.setId(g.getId());
dto.setName(g.getName());
dto.setDescription(g.getDescription());
dto.setRulesMarkdown(g.getRulesMarkdown());
dto.setAuthor(g.getAuthor());
dto.setPublic(g.isPublic());
return dto;
}
public GameSystem toDomain(GameSystemDTO dto) {
if (dto == null) return null;
return GameSystem.builder()
.id(dto.getId())
.name(dto.getName())
.description(dto.getDescription())
.rulesMarkdown(dto.getRulesMarkdown())
.author(dto.getAuthor())
.isPublic(dto.isPublic())
.build();
}
}

View File

@@ -1,7 +1,11 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -14,6 +18,7 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
@@ -26,6 +31,10 @@ public class ArcServiceTest {
@Mock
private ArcRepository arcRepository;
@Mock
private ChapterRepository chapterRepository;
@Mock
private SceneRepository sceneRepository;
@InjectMocks
private ArcService arcService;
@@ -159,15 +168,48 @@ public class ArcServiceTest {
}
@Test
void testDeleteArc() {
// Arrange
doNothing().when(arcRepository).deleteById("arc-1");
// Act
void testDeleteArc_EmptyArc() {
// Aucun chapitre : Mockito renvoie List.of() par défaut.
arcService.deleteArc("arc-1");
// Assert
verify(arcRepository, times(1)).deleteById("arc-1");
verify(arcRepository).deleteById("arc-1");
verify(chapterRepository, never()).deleteById(anyString());
verify(sceneRepository, never()).deleteById(anyString());
}
@Test
void testDeleteArc_CascadesChaptersAndScenes() {
Chapter chapter = Chapter.builder().id("chap-1").arcId("arc-1").name("C").build();
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
Scene s2 = Scene.builder().id("s-2").chapterId("chap-1").name("S2").build();
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(chapter));
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1, s2));
arcService.deleteArc("arc-1");
verify(sceneRepository).deleteById("s-1");
verify(sceneRepository).deleteById("s-2");
verify(chapterRepository).deleteById("chap-1");
verify(arcRepository).deleteById("arc-1");
}
@Test
void testGetDeletionImpact() {
Chapter c1 = Chapter.builder().id("chap-1").arcId("arc-1").name("C1").build();
Chapter c2 = Chapter.builder().id("chap-2").arcId("arc-1").name("C2").build();
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
Scene s2 = Scene.builder().id("s-2").chapterId("chap-2").name("S2").build();
Scene s3 = Scene.builder().id("s-3").chapterId("chap-2").name("S3").build();
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(c1, c2));
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1));
when(sceneRepository.findByChapterId("chap-2")).thenReturn(List.of(s2, s3));
ArcService.DeletionImpact impact = arcService.getDeletionImpact("arc-1");
assertEquals(2, impact.chapters());
assertEquals(3, impact.scenes());
}
@Test

View File

@@ -1,7 +1,15 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -27,6 +35,14 @@ public class CampaignServiceTest {
@Mock
private CampaignRepository campaignRepository;
@Mock
private ArcRepository arcRepository;
@Mock
private ChapterRepository chapterRepository;
@Mock
private SceneRepository sceneRepository;
@Mock
private CharacterRepository characterRepository;
@InjectMocks
private CampaignService campaignService;
@@ -50,9 +66,13 @@ public class CampaignServiceTest {
CampaignService.CampaignData data = new CampaignService.CampaignData(
"New Campaign",
"Description",
"lore-123"
"lore-123",
null
);
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
// Le repo renvoie la Campaign telle que passée — on teste la normalisation
// du loreId dans le service, pas le comportement du repo.
when(campaignRepository.save(any(Campaign.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// Act
Campaign result = campaignService.createCampaign(data);
@@ -69,9 +89,11 @@ public class CampaignServiceTest {
CampaignService.CampaignData data = new CampaignService.CampaignData(
"New Campaign",
"Description",
null,
null
);
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
when(campaignRepository.save(any(Campaign.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// Act
Campaign result = campaignService.createCampaign(data);
@@ -88,9 +110,11 @@ public class CampaignServiceTest {
CampaignService.CampaignData data = new CampaignService.CampaignData(
"New Campaign",
"Description",
" "
" ",
null
);
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
when(campaignRepository.save(any(Campaign.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// Act
Campaign result = campaignService.createCampaign(data);
@@ -151,7 +175,8 @@ public class CampaignServiceTest {
CampaignService.CampaignData data = new CampaignService.CampaignData(
"Updated Campaign",
"Updated Description",
"lore-456"
"lore-456",
null
);
when(campaignRepository.findById("campaign-1")).thenReturn(Optional.of(testCampaign));
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
@@ -171,7 +196,8 @@ public class CampaignServiceTest {
CampaignService.CampaignData data = new CampaignService.CampaignData(
"Updated Campaign",
"Updated Description",
"lore-456"
"lore-456",
null
);
when(campaignRepository.findById("invalid-id")).thenReturn(Optional.empty());
@@ -186,15 +212,75 @@ public class CampaignServiceTest {
}
@Test
void testDeleteCampaign() {
// Arrange
doNothing().when(campaignRepository).deleteById("campaign-1");
void testDeleteCampaign_EmptyCampaign() {
// Arrange : aucune dépendance ; Mockito renvoie List.of() par défaut.
// Act
campaignService.deleteCampaign("campaign-1");
// Assert
verify(campaignRepository, times(1)).deleteById("campaign-1");
verify(arcRepository, never()).deleteById(anyString());
verify(chapterRepository, never()).deleteById(anyString());
verify(sceneRepository, never()).deleteById(anyString());
verify(characterRepository, never()).deleteById(anyString());
}
@Test
void testDeleteCampaign_CascadesArcsChaptersScenes() {
// Arrange : campagne avec 1 arc → 1 chapitre → 2 scènes.
Arc arc = Arc.builder().id("arc-1").campaignId("campaign-1").name("Arc 1").build();
Chapter chapter = Chapter.builder().id("chap-1").arcId("arc-1").name("Chap 1").build();
Scene scene1 = Scene.builder().id("scene-1").chapterId("chap-1").name("Scene 1").build();
Scene scene2 = Scene.builder().id("scene-2").chapterId("chap-1").name("Scene 2").build();
when(arcRepository.findByCampaignId("campaign-1")).thenReturn(List.of(arc));
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(chapter));
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(scene1, scene2));
// Act
campaignService.deleteCampaign("campaign-1");
// Assert : tout disparaît, dans l'ordre feuilles → racine.
verify(sceneRepository).deleteById("scene-1");
verify(sceneRepository).deleteById("scene-2");
verify(chapterRepository).deleteById("chap-1");
verify(arcRepository).deleteById("arc-1");
verify(campaignRepository).deleteById("campaign-1");
}
@Test
void testDeleteCampaign_CascadesCharacters() {
Character pc = Character.builder().id("char-1").campaignId("campaign-1").name("Alric").build();
when(characterRepository.findByCampaignId("campaign-1")).thenReturn(List.of(pc));
campaignService.deleteCampaign("campaign-1");
verify(characterRepository).deleteById("char-1");
verify(campaignRepository).deleteById("campaign-1");
}
@Test
void testGetDeletionImpact() {
Arc arc = Arc.builder().id("arc-1").campaignId("campaign-1").name("Arc 1").build();
Chapter c1 = Chapter.builder().id("chap-1").arcId("arc-1").name("C1").build();
Chapter c2 = Chapter.builder().id("chap-2").arcId("arc-1").name("C2").build();
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
Scene s2 = Scene.builder().id("s-2").chapterId("chap-2").name("S2").build();
Scene s3 = Scene.builder().id("s-3").chapterId("chap-2").name("S3").build();
Character pc = Character.builder().id("char-1").campaignId("campaign-1").name("Alric").build();
when(arcRepository.findByCampaignId("campaign-1")).thenReturn(List.of(arc));
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(c1, c2));
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1));
when(sceneRepository.findByChapterId("chap-2")).thenReturn(List.of(s2, s3));
when(characterRepository.findByCampaignId("campaign-1")).thenReturn(List.of(pc));
CampaignService.DeletionImpact impact = campaignService.getDeletionImpact("campaign-1");
assertEquals(1, impact.arcs());
assertEquals(2, impact.chapters());
assertEquals(3, impact.scenes());
assertEquals(1, impact.characters());
}
@Test

View File

@@ -1,7 +1,9 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -14,6 +16,7 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
@@ -26,6 +29,8 @@ public class ChapterServiceTest {
@Mock
private ChapterRepository chapterRepository;
@Mock
private SceneRepository sceneRepository;
@InjectMocks
private ChapterService chapterService;
@@ -157,15 +162,36 @@ public class ChapterServiceTest {
}
@Test
void testDeleteChapter() {
// Arrange
doNothing().when(chapterRepository).deleteById("chapter-1");
// Act
void testDeleteChapter_EmptyChapter() {
// Aucune scène : Mockito renvoie List.of() par défaut.
chapterService.deleteChapter("chapter-1");
// Assert
verify(chapterRepository, times(1)).deleteById("chapter-1");
verify(chapterRepository).deleteById("chapter-1");
verify(sceneRepository, never()).deleteById(anyString());
}
@Test
void testDeleteChapter_CascadesScenes() {
Scene s1 = Scene.builder().id("s-1").chapterId("chapter-1").name("S1").build();
Scene s2 = Scene.builder().id("s-2").chapterId("chapter-1").name("S2").build();
when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(s1, s2));
chapterService.deleteChapter("chapter-1");
verify(sceneRepository).deleteById("s-1");
verify(sceneRepository).deleteById("s-2");
verify(chapterRepository).deleteById("chapter-1");
}
@Test
void testGetDeletionImpact() {
Scene s1 = Scene.builder().id("s-1").chapterId("chapter-1").name("S1").build();
Scene s2 = Scene.builder().id("s-2").chapterId("chapter-1").name("S2").build();
when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(s1, s2));
ChapterService.DeletionImpact impact = chapterService.getDeletionImpact("chapter-1");
assertEquals(2, impact.scenes());
}
@Test

View File

@@ -0,0 +1,323 @@
package com.loremind.application.conversationcontext;
import com.loremind.domain.conversationcontext.Conversation;
import com.loremind.domain.conversationcontext.ConversationMessage;
import com.loremind.domain.conversationcontext.ports.ConversationRepository;
import com.loremind.domain.conversationcontext.ports.ConversationTitleGenerator;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* Tests unitaires de ConversationService.
* Focus sur :
* - la validation XOR de l'ancrage (loreId XOR campaignId),
* - la coherence entityType/entityId (tous deux null ou tous deux non-null),
* - le fallback du titre a la creation,
* - la validation des roles et contenus de message,
* - l'auto-generation de titre (cas succes + court-circuit si pas de messages).
*/
@ExtendWith(MockitoExtension.class)
class ConversationServiceTest {
@Mock
private ConversationRepository repository;
@Mock
private ConversationTitleGenerator titleGenerator;
@InjectMocks
private ConversationService service;
// ---------- create : validation XOR de l'ancrage -----------------------
@Test
void create_rejectsBothAnchorsNull() {
ConversationService.CreateData data = new ConversationService.CreateData(
"t", null, null, null, null);
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> service.create(data));
assertEquals("Exactement un parent attendu : loreId XOR campaignId", ex.getMessage());
verifyNoInteractions(repository);
}
@Test
void create_rejectsBothAnchorsPresent() {
ConversationService.CreateData data = new ConversationService.CreateData(
"t", "lore-1", "camp-1", null, null);
assertThrows(IllegalArgumentException.class, () -> service.create(data));
verifyNoInteractions(repository);
}
@Test
void create_rejectsBlankLoreIdAsAbsent() {
// Blank (espaces) = absent : c'est la regle du service.
ConversationService.CreateData data = new ConversationService.CreateData(
"t", " ", " ", null, null);
assertThrows(IllegalArgumentException.class, () -> service.create(data));
}
@Test
void create_rejectsEntityTypeWithoutEntityId() {
ConversationService.CreateData data = new ConversationService.CreateData(
"t", "lore-1", null, "page", null);
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> service.create(data));
assertEquals("entityType et entityId doivent etre tous deux null ou tous deux non-null", ex.getMessage());
}
@Test
void create_rejectsEntityIdWithoutEntityType() {
ConversationService.CreateData data = new ConversationService.CreateData(
"t", "lore-1", null, null, "page-42");
assertThrows(IllegalArgumentException.class, () -> service.create(data));
}
// ---------- create : cas nominaux --------------------------------------
@Test
void create_withLoreAnchor_persistsBuiltConversation() {
ConversationService.CreateData data = new ConversationService.CreateData(
"Discussion Thorin", "lore-1", null, "page", "page-42");
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Conversation result = service.create(data);
ArgumentCaptor<Conversation> captor = ArgumentCaptor.forClass(Conversation.class);
verify(repository).save(captor.capture());
Conversation saved = captor.getValue();
assertEquals("Discussion Thorin", saved.getTitle());
assertEquals("lore-1", saved.getLoreId());
assertEquals("page", saved.getEntityType());
assertEquals("page-42", saved.getEntityId());
assertEquals(saved, result);
}
@Test
void create_withCampaignAnchor_andNoEntityFocus_persistsRootLevel() {
ConversationService.CreateData data = new ConversationService.CreateData(
null, null, "camp-1", null, null);
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.create(data);
ArgumentCaptor<Conversation> captor = ArgumentCaptor.forClass(Conversation.class);
verify(repository).save(captor.capture());
assertEquals("Nouvelle conversation", captor.getValue().getTitle(),
"Titre absent -> fallback par defaut");
assertEquals("camp-1", captor.getValue().getCampaignId());
}
@Test
void create_trimsProvidedTitle() {
ConversationService.CreateData data = new ConversationService.CreateData(
" Mon titre ", "lore-1", null, null, null);
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.create(data);
ArgumentCaptor<Conversation> captor = ArgumentCaptor.forClass(Conversation.class);
verify(repository).save(captor.capture());
assertEquals("Mon titre", captor.getValue().getTitle());
}
@Test
void create_blankTitle_fallsBackToDefault() {
ConversationService.CreateData data = new ConversationService.CreateData(
" ", "lore-1", null, null, null);
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.create(data);
ArgumentCaptor<Conversation> captor = ArgumentCaptor.forClass(Conversation.class);
verify(repository).save(captor.capture());
assertEquals("Nouvelle conversation", captor.getValue().getTitle());
}
// ---------- getById / listByContext / delete --------------------------
@Test
void getById_delegatesToRepository() {
Conversation conv = Conversation.builder().id("c-1").build();
when(repository.findById("c-1")).thenReturn(Optional.of(conv));
assertEquals(Optional.of(conv), service.getById("c-1"));
}
@Test
void listByContext_validatesAnchorBeforeQuerying() {
assertThrows(IllegalArgumentException.class,
() -> service.listByContext(null, null, null, null));
verifyNoInteractions(repository);
}
@Test
void listByContext_delegates_whenAnchorValid() {
Conversation c = Conversation.builder().id("c-1").build();
when(repository.findByContext("lore-1", null, null, null)).thenReturn(List.of(c));
List<Conversation> result = service.listByContext("lore-1", null, null, null);
assertEquals(1, result.size());
}
@Test
void delete_delegatesToRepository() {
service.delete("c-1");
verify(repository).deleteById("c-1");
}
// ---------- rename -----------------------------------------------------
@Test
void rename_rejectsNullTitle() {
assertThrows(IllegalArgumentException.class, () -> service.rename("c-1", null));
verify(repository, never()).updateTitle(anyString(), anyString());
}
@Test
void rename_rejectsBlankTitle() {
assertThrows(IllegalArgumentException.class, () -> service.rename("c-1", " "));
verify(repository, never()).updateTitle(anyString(), anyString());
}
@Test
void rename_rejectsUnknownConversation() {
when(repository.findById("unknown")).thenReturn(Optional.empty());
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> service.rename("unknown", "Nouveau titre"));
assertEquals("Conversation introuvable : unknown", ex.getMessage());
verify(repository, never()).updateTitle(anyString(), anyString());
}
@Test
void rename_trimsTitleBeforePersist() {
when(repository.findById("c-1")).thenReturn(Optional.of(Conversation.builder().id("c-1").build()));
service.rename("c-1", " Nouveau titre ");
verify(repository).updateTitle("c-1", "Nouveau titre");
}
// ---------- appendMessage ----------------------------------------------
@Test
void appendMessage_rejectsInvalidRole() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> service.appendMessage("c-1", "admin", "hello"));
assertEquals("Role invalide : admin", ex.getMessage());
verifyNoInteractions(repository);
}
@Test
void appendMessage_rejectsNullRole() {
assertThrows(IllegalArgumentException.class,
() -> service.appendMessage("c-1", null, "hello"));
}
@Test
void appendMessage_rejectsNullContent() {
assertThrows(IllegalArgumentException.class,
() -> service.appendMessage("c-1", "user", null));
}
@Test
void appendMessage_rejectsEmptyContent() {
assertThrows(IllegalArgumentException.class,
() -> service.appendMessage("c-1", "user", ""));
}
@Test
void appendMessage_acceptsAllThreeCanonicalRoles() {
ConversationMessage returned = ConversationMessage.builder().id("m").build();
when(repository.appendMessage(eq("c-1"), any())).thenReturn(returned);
for (String role : new String[]{"user", "assistant", "system"}) {
service.appendMessage("c-1", role, "contenu");
}
ArgumentCaptor<ConversationMessage> captor = ArgumentCaptor.forClass(ConversationMessage.class);
verify(repository, times(3)).appendMessage(eq("c-1"), captor.capture());
assertEquals("user", captor.getAllValues().get(0).getRole());
assertEquals("assistant", captor.getAllValues().get(1).getRole());
assertEquals("system", captor.getAllValues().get(2).getRole());
}
@Test
void appendMessage_passesContentVerbatim() {
ConversationMessage returned = ConversationMessage.builder().id("m-1").build();
when(repository.appendMessage(eq("c-1"), any())).thenReturn(returned);
service.appendMessage("c-1", "user", " hello avec espaces ");
ArgumentCaptor<ConversationMessage> captor = ArgumentCaptor.forClass(ConversationMessage.class);
verify(repository).appendMessage(eq("c-1"), captor.capture());
// Le service NE trim PAS le contenu — seul le titre est trim.
assertEquals(" hello avec espaces ", captor.getValue().getContent());
}
// ---------- autoGenerateTitle ------------------------------------------
@Test
void autoGenerateTitle_throws_whenConversationNotFound() {
when(repository.findById("unknown")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class, () -> service.autoGenerateTitle("unknown"));
verifyNoInteractions(titleGenerator);
}
@Test
void autoGenerateTitle_shortCircuits_whenNoMessages() {
Conversation conv = Conversation.builder().id("c-1").title("Titre existant").messages(List.of()).build();
when(repository.findById("c-1")).thenReturn(Optional.of(conv));
String result = service.autoGenerateTitle("c-1");
assertEquals("Titre existant", result);
verifyNoInteractions(titleGenerator);
verify(repository, never()).updateTitle(anyString(), anyString());
}
@Test
void autoGenerateTitle_shortCircuits_whenMessagesIsNull() {
Conversation conv = new Conversation(); // @NoArgsConstructor -> messages == null
conv.setId("c-1");
conv.setTitle("Titre");
when(repository.findById("c-1")).thenReturn(Optional.of(conv));
assertEquals("Titre", service.autoGenerateTitle("c-1"));
verifyNoInteractions(titleGenerator);
}
@Test
void autoGenerateTitle_generatesAndPersists_whenMessagesPresent() {
List<ConversationMessage> seeds = List.of(
ConversationMessage.builder().role("user").content("bonjour").build(),
ConversationMessage.builder().role("assistant").content("salut").build());
Conversation conv = Conversation.builder().id("c-1").title("Ancien").messages(seeds).build();
when(repository.findById("c-1")).thenReturn(Optional.of(conv));
when(titleGenerator.generate(seeds)).thenReturn("Premier echange poli");
String result = service.autoGenerateTitle("c-1");
assertEquals("Premier echange poli", result);
verify(repository).updateTitle("c-1", "Premier echange poli");
}
}

View File

@@ -8,6 +8,7 @@ import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import org.junit.jupiter.api.BeforeEach;
@@ -40,6 +41,8 @@ public class CampaignStructuralContextBuilderTest {
private ChapterRepository chapterRepository;
@Mock
private SceneRepository sceneRepository;
@Mock
private CharacterRepository characterRepository;
@InjectMocks
private CampaignStructuralContextBuilder builder;

View File

@@ -1,7 +1,9 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -15,6 +17,7 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
@@ -26,6 +29,7 @@ import static org.mockito.Mockito.*;
public class LoreNodeServiceTest {
@Mock private LoreNodeRepository loreNodeRepository;
@Mock private PageRepository pageRepository;
@InjectMocks private LoreNodeService loreNodeService;
@@ -118,8 +122,66 @@ public class LoreNodeServiceTest {
}
@Test
void testDelete() {
void testDelete_LeafFolder() {
// Aucun descendant, aucune page : seul le dossier est supprimé.
loreNodeService.deleteLoreNode("n-1");
verify(loreNodeRepository).deleteById("n-1");
verify(pageRepository, never()).deleteById(anyString());
}
@Test
void testDelete_CascadesPagesOfRoot() {
Page p1 = Page.builder().id("p-1").nodeId("n-1").title("P1").build();
Page p2 = Page.builder().id("p-2").nodeId("n-1").title("P2").build();
when(pageRepository.findByNodeId("n-1")).thenReturn(List.of(p1, p2));
loreNodeService.deleteLoreNode("n-1");
verify(pageRepository).deleteById("p-1");
verify(pageRepository).deleteById("p-2");
verify(loreNodeRepository).deleteById("n-1");
}
@Test
void testDelete_CascadesSubfoldersRecursive() {
// n-1 → n-1a → n-1a1 ; chaque feuille a une page.
LoreNode mid = LoreNode.builder().id("n-1a").parentId("n-1").loreId("lore-1").name("mid").build();
LoreNode leaf = LoreNode.builder().id("n-1a1").parentId("n-1a").loreId("lore-1").name("leaf").build();
Page pageOnLeaf = Page.builder().id("p-leaf").nodeId("n-1a1").title("P").build();
when(loreNodeRepository.findByParentId("n-1")).thenReturn(List.of(mid));
when(loreNodeRepository.findByParentId("n-1a")).thenReturn(List.of(leaf));
when(pageRepository.findByNodeId("n-1a1")).thenReturn(List.of(pageOnLeaf));
loreNodeService.deleteLoreNode("n-1");
// Feuilles d'abord (pages puis dossier leaf), puis mid, puis la racine.
verify(pageRepository).deleteById("p-leaf");
verify(loreNodeRepository).deleteById("n-1a1");
verify(loreNodeRepository).deleteById("n-1a");
verify(loreNodeRepository).deleteById("n-1");
}
@Test
void testGetDeletionImpact_CountsSubfoldersAndPages() {
LoreNode sub1 = LoreNode.builder().id("s-1").parentId("n-1").loreId("lore-1").name("s1").build();
LoreNode sub2 = LoreNode.builder().id("s-2").parentId("n-1").loreId("lore-1").name("s2").build();
LoreNode subsub = LoreNode.builder().id("s-1a").parentId("s-1").loreId("lore-1").name("s1a").build();
Page p1 = Page.builder().id("p-1").nodeId("n-1").title("P1").build();
Page p2 = Page.builder().id("p-2").nodeId("s-1").title("P2").build();
Page p3 = Page.builder().id("p-3").nodeId("s-1a").title("P3").build();
when(loreNodeRepository.findByParentId("n-1")).thenReturn(List.of(sub1, sub2));
when(loreNodeRepository.findByParentId("s-1")).thenReturn(List.of(subsub));
when(pageRepository.findByNodeId("n-1")).thenReturn(List.of(p1));
when(pageRepository.findByNodeId("s-1")).thenReturn(List.of(p2));
when(pageRepository.findByNodeId("s-2")).thenReturn(List.of());
when(pageRepository.findByNodeId("s-1a")).thenReturn(List.of(p3));
LoreNodeService.DeletionImpact impact = loreNodeService.getDeletionImpact("n-1");
// 3 sous-dossiers (sub1, sub2, subsub) — on ne compte pas la racine n-1.
assertEquals(3, impact.folders());
assertEquals(3, impact.pages());
}
}

View File

@@ -1,9 +1,15 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -17,6 +23,7 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
@@ -30,6 +37,8 @@ public class LoreServiceTest {
@Mock private LoreRepository loreRepository;
@Mock private LoreNodeRepository loreNodeRepository;
@Mock private PageRepository pageRepository;
@Mock private TemplateRepository templateRepository;
@Mock private CampaignRepository campaignRepository;
@InjectMocks private LoreService loreService;
@@ -134,8 +143,67 @@ public class LoreServiceTest {
}
@Test
void testDeleteLore_DelegatesToRepository() {
void testDeleteLore_EmptyLore() {
// Aucun dossier / page / template / campagne : seul le Lore est supprimé.
loreService.deleteLore("lore-1");
verify(loreRepository).deleteById("lore-1");
verify(loreNodeRepository, never()).deleteById(anyString());
verify(pageRepository, never()).deleteById(anyString());
verify(templateRepository, never()).deleteById(anyString());
}
@Test
void testDeleteLore_CascadesFoldersPagesTemplates() {
LoreNode node = LoreNode.builder().id("n-1").loreId("lore-1").name("F").build();
Page page = Page.builder().id("p-1").loreId("lore-1").nodeId("n-1").title("P").build();
Template template = Template.builder().id("t-1").loreId("lore-1").name("T").build();
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(page));
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(node));
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(template));
loreService.deleteLore("lore-1");
verify(pageRepository).deleteById("p-1");
verify(loreNodeRepository).deleteById("n-1");
verify(templateRepository).deleteById("t-1");
verify(loreRepository).deleteById("lore-1");
}
@Test
void testDeleteLore_DetachesCampaignsInsteadOfDeleting() {
Campaign attached = Campaign.builder().id("c-1").loreId("lore-1").name("C1").build();
Campaign other = Campaign.builder().id("c-2").loreId("lore-other").name("C2").build();
Campaign orphan = Campaign.builder().id("c-3").loreId(null).name("C3").build();
when(campaignRepository.findAll()).thenReturn(List.of(attached, other, orphan));
when(campaignRepository.save(any(Campaign.class))).thenAnswer(inv -> inv.getArgument(0));
loreService.deleteLore("lore-1");
// Seule la campagne attachée est re-sauvegardée (avec loreId=null).
ArgumentCaptor<Campaign> captor = ArgumentCaptor.forClass(Campaign.class);
verify(campaignRepository, times(1)).save(captor.capture());
assertEquals("c-1", captor.getValue().getId());
assertNull(captor.getValue().getLoreId());
// Aucune campagne n'est supprimée.
verify(campaignRepository, never()).deleteById(anyString());
}
@Test
void testGetDeletionImpact() {
Template t1 = Template.builder().id("t-1").loreId("lore-1").name("T").build();
Campaign attached = Campaign.builder().id("c-1").loreId("lore-1").name("C").build();
Campaign unrelated = Campaign.builder().id("c-2").loreId("lore-other").name("C2").build();
when(loreNodeRepository.countByLoreId("lore-1")).thenReturn(4L);
when(pageRepository.countByLoreId("lore-1")).thenReturn(12L);
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(t1));
when(campaignRepository.findAll()).thenReturn(List.of(attached, unrelated));
LoreService.DeletionImpact impact = loreService.getDeletionImpact("lore-1");
assertEquals(4, impact.folders());
assertEquals(12, impact.pages());
assertEquals(1, impact.templates());
assertEquals(1, impact.detachedCampaigns());
}
}

View File

@@ -0,0 +1,64 @@
package com.loremind.domain.campaigncontext;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires du domaine pour Arc.
* Focus sur les @Builder.Default des collections : la moindre omission
* renverrait {@code null} et propagerait des NPE dans toute la pile.
*/
class ArcTest {
@Test
void builder_initializesAllCollectionsToEmptyList_whenNotSet() {
Arc arc = Arc.builder()
.id("arc-1")
.name("Acte I")
.campaignId("camp-1")
.order(0)
.build();
assertNotNull(arc.getRelatedPageIds(), "relatedPageIds ne doit jamais etre null");
assertNotNull(arc.getIllustrationImageIds(), "illustrationImageIds ne doit jamais etre null");
assertNotNull(arc.getMapImageIds(), "mapImageIds ne doit jamais etre null");
assertTrue(arc.getRelatedPageIds().isEmpty());
assertTrue(arc.getIllustrationImageIds().isEmpty());
assertTrue(arc.getMapImageIds().isEmpty());
}
@Test
void builder_preservesProvidedCollections() {
Arc arc = Arc.builder()
.relatedPageIds(List.of("page-a", "page-b"))
.illustrationImageIds(List.of("img-1"))
.mapImageIds(List.of("map-1", "map-2", "map-3"))
.build();
assertEquals(2, arc.getRelatedPageIds().size());
assertEquals(1, arc.getIllustrationImageIds().size());
assertEquals(3, arc.getMapImageIds().size());
}
@Test
void builder_preservesNarrativeEnrichmentFields() {
Arc arc = Arc.builder()
.themes("trahison, vengeance")
.stakes("la survie du royaume")
.gmNotes("secret : le roi est un imposteur")
.rewards("artefact ancien")
.resolution("couronnement du legitime heritier")
.build();
assertEquals("trahison, vengeance", arc.getThemes());
assertEquals("la survie du royaume", arc.getStakes());
assertEquals("secret : le roi est un imposteur", arc.getGmNotes());
assertEquals("artefact ancien", arc.getRewards());
assertEquals("couronnement du legitime heritier", arc.getResolution());
}
}

View File

@@ -0,0 +1,75 @@
package com.loremind.domain.campaigncontext;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires du domaine pour Campaign.
* Valide la seule methode metier ({@code isLinkedToLore}) et les invariants
* de construction du builder Lombok (defaults).
*/
class CampaignTest {
// --- isLinkedToLore : regle metier du Bounded Context --------------------
@Test
void isLinkedToLore_returnsFalse_whenLoreIdIsNull() {
Campaign campaign = Campaign.builder()
.id("c1")
.name("One-shot")
.loreId(null)
.build();
assertFalse(campaign.isLinkedToLore());
}
@Test
void isLinkedToLore_returnsFalse_whenLoreIdIsEmpty() {
Campaign campaign = Campaign.builder().loreId("").build();
assertFalse(campaign.isLinkedToLore());
}
@Test
void isLinkedToLore_returnsFalse_whenLoreIdIsBlank() {
// Blank = espaces / tabulations uniquement → ne doit pas compter comme un lien valide.
Campaign campaign = Campaign.builder().loreId(" ").build();
assertFalse(campaign.isLinkedToLore());
}
@Test
void isLinkedToLore_returnsTrue_whenLoreIdIsPresent() {
Campaign campaign = Campaign.builder().loreId("lore-42").build();
assertTrue(campaign.isLinkedToLore());
}
// --- Invariants de construction -----------------------------------------
@Test
void builder_preservesAllScalarFields() {
Campaign campaign = Campaign.builder()
.id("c-1")
.name("Les Ombres d'Ithoril")
.description("Une campagne de faction dans un royaume en decadence.")
.loreId("lore-1")
.arcsCount(3)
.build();
assertEquals("c-1", campaign.getId());
assertEquals("Les Ombres d'Ithoril", campaign.getName());
assertEquals("Une campagne de faction dans un royaume en decadence.", campaign.getDescription());
assertEquals("lore-1", campaign.getLoreId());
assertEquals(3, campaign.getArcsCount());
}
@Test
void builder_allowsNoArgs_forFlexibility() {
// Un Campaign peut etre cree sans champ rempli (cas pre-hydratation depuis DB).
Campaign campaign = Campaign.builder().build();
assertNotNull(campaign);
assertFalse(campaign.isLinkedToLore());
}
}

View File

@@ -0,0 +1,60 @@
package com.loremind.domain.campaigncontext;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires du domaine pour Chapter.
* Verifie l'initialisation des collections via @Builder.Default et la
* preservation des champs narratifs enrichis.
*/
class ChapterTest {
@Test
void builder_initializesAllCollectionsToEmptyList_whenNotSet() {
Chapter chapter = Chapter.builder()
.id("ch-1")
.name("L'arrivee")
.arcId("arc-1")
.order(0)
.build();
assertNotNull(chapter.getRelatedPageIds());
assertNotNull(chapter.getIllustrationImageIds());
assertNotNull(chapter.getMapImageIds());
assertTrue(chapter.getRelatedPageIds().isEmpty());
assertTrue(chapter.getIllustrationImageIds().isEmpty());
assertTrue(chapter.getMapImageIds().isEmpty());
}
@Test
void builder_preservesProvidedCollections() {
Chapter chapter = Chapter.builder()
.relatedPageIds(List.of("page-x"))
.illustrationImageIds(List.of("img-1", "img-2"))
.mapImageIds(List.of("map-dungeon"))
.build();
assertEquals(1, chapter.getRelatedPageIds().size());
assertEquals(2, chapter.getIllustrationImageIds().size());
assertEquals(1, chapter.getMapImageIds().size());
}
@Test
void builder_preservesNarrativeEnrichmentFields() {
Chapter chapter = Chapter.builder()
.gmNotes("les joueurs doivent decouvrir la trahison")
.playerObjectives("trouver l'indice dans la bibliotheque")
.narrativeStakes("si echec, l'allie meurt")
.build();
assertEquals("les joueurs doivent decouvrir la trahison", chapter.getGmNotes());
assertEquals("trouver l'indice dans la bibliotheque", chapter.getPlayerObjectives());
assertEquals("si echec, l'allie meurt", chapter.getNarrativeStakes());
}
}

View File

@@ -0,0 +1,74 @@
package com.loremind.domain.campaigncontext;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Tests unitaires pour SceneBranch (Value Object).
* Verifie :
* - l'immuabilite (pas de setters : seul le builder permet la construction),
* - l'egalite structurelle generee par @Value (equals/hashCode sur tous les
* champs) — deux branches aux memes champs sont strictement egales,
* - le support du champ optionnel {@code condition}.
*/
class SceneBranchTest {
@Test
void builder_exposesAllFields() {
SceneBranch branch = SceneBranch.builder()
.label("Si les joueurs attaquent le garde")
.targetSceneId("sc-combat")
.condition("initiative > 15")
.build();
assertEquals("Si les joueurs attaquent le garde", branch.getLabel());
assertEquals("sc-combat", branch.getTargetSceneId());
assertEquals("initiative > 15", branch.getCondition());
}
@Test
void condition_isOptional() {
SceneBranch branch = SceneBranch.builder()
.label("sortie par la porte")
.targetSceneId("sc-corridor")
.build();
assertNull(branch.getCondition());
}
@Test
void twoBranches_withSameFields_areEqual() {
SceneBranch a = SceneBranch.builder()
.label("fuite")
.targetSceneId("sc-2")
.condition(null)
.build();
SceneBranch b = SceneBranch.builder()
.label("fuite")
.targetSceneId("sc-2")
.condition(null)
.build();
assertEquals(a, b);
assertEquals(a.hashCode(), b.hashCode());
}
@Test
void twoBranches_differingOnTargetSceneId_areNotEqual() {
SceneBranch a = SceneBranch.builder().label("X").targetSceneId("sc-1").build();
SceneBranch b = SceneBranch.builder().label("X").targetSceneId("sc-2").build();
assertNotEquals(a, b);
}
@Test
void twoBranches_differingOnCondition_areNotEqual() {
SceneBranch a = SceneBranch.builder().label("X").targetSceneId("sc-1").condition("A").build();
SceneBranch b = SceneBranch.builder().label("X").targetSceneId("sc-1").condition("B").build();
assertNotEquals(a, b);
}
}

View File

@@ -0,0 +1,74 @@
package com.loremind.domain.campaigncontext;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires du domaine pour Scene.
* Scene est la plus riche en champs : on valide les quatre collections
* @Builder.Default (relatedPageIds, illustrationImageIds, mapImageIds, branches)
* et la preservation de l'ensemble des champs narratifs.
*/
class SceneTest {
@Test
void builder_initializesAllCollectionsToEmptyList_whenNotSet() {
Scene scene = Scene.builder()
.id("sc-1")
.name("L'auberge")
.chapterId("ch-1")
.order(0)
.build();
assertNotNull(scene.getRelatedPageIds());
assertNotNull(scene.getIllustrationImageIds());
assertNotNull(scene.getMapImageIds());
assertNotNull(scene.getBranches(), "branches ne doit jamais etre null — une scene sans branche est une feuille");
assertTrue(scene.getRelatedPageIds().isEmpty());
assertTrue(scene.getIllustrationImageIds().isEmpty());
assertTrue(scene.getMapImageIds().isEmpty());
assertTrue(scene.getBranches().isEmpty());
}
@Test
void builder_preservesAllNarrativeFields() {
Scene scene = Scene.builder()
.location("Taverne du Dragon d'Or")
.timing("Soir, a la tombee de la nuit")
.atmosphere("fumee, rires rauques, odeur de biere")
.playerNarration("Vous poussez la porte et entrez dans...")
.gmSecretNotes("l'aubergiste est un espion de la guilde")
.choicesConsequences("si les PJ parlent fort, ils attirent les gardes")
.combatDifficulty("facile (CR 1/2)")
.enemies("3 brigands armes de gourdins")
.build();
assertEquals("Taverne du Dragon d'Or", scene.getLocation());
assertEquals("Soir, a la tombee de la nuit", scene.getTiming());
assertEquals("fumee, rires rauques, odeur de biere", scene.getAtmosphere());
assertEquals("Vous poussez la porte et entrez dans...", scene.getPlayerNarration());
assertEquals("l'aubergiste est un espion de la guilde", scene.getGmSecretNotes());
assertEquals("si les PJ parlent fort, ils attirent les gardes", scene.getChoicesConsequences());
assertEquals("facile (CR 1/2)", scene.getCombatDifficulty());
assertEquals("3 brigands armes de gourdins", scene.getEnemies());
}
@Test
void builder_preservesBranches_whenProvided() {
SceneBranch b1 = SceneBranch.builder().label("fuite").targetSceneId("sc-2").build();
SceneBranch b2 = SceneBranch.builder().label("combat").targetSceneId("sc-3").build();
Scene scene = Scene.builder()
.branches(List.of(b1, b2))
.build();
assertEquals(2, scene.getBranches().size());
assertEquals("fuite", scene.getBranches().get(0).getLabel());
assertEquals("sc-3", scene.getBranches().get(1).getTargetSceneId());
}
}

View File

@@ -0,0 +1,60 @@
package com.loremind.domain.conversationcontext;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Tests unitaires pour ConversationMessage.
* Entite persistee (@Data mutable) : distinct du record ChatMessage du
* generationcontext — ici on ajoute id et horodatage pour l'affichage.
*/
class ConversationMessageTest {
@Test
void builder_preservesAllFields() {
LocalDateTime now = LocalDateTime.now();
ConversationMessage msg = ConversationMessage.builder()
.id("msg-1")
.role("user")
.content("Salut")
.createdAt(now)
.build();
assertEquals("msg-1", msg.getId());
assertEquals("user", msg.getRole());
assertEquals("Salut", msg.getContent());
assertEquals(now, msg.getCreatedAt());
}
@Test
void noArgsConstructor_yieldsEmptyMessage() {
ConversationMessage msg = new ConversationMessage();
assertNull(msg.getId());
assertNull(msg.getRole());
assertNull(msg.getContent());
}
@Test
void allArgsConstructor_populatesEveryField() {
LocalDateTime now = LocalDateTime.now();
ConversationMessage msg = new ConversationMessage("m-1", "assistant", "Reponse", now);
assertNotNull(msg);
assertEquals("assistant", msg.getRole());
assertEquals("Reponse", msg.getContent());
}
@Test
void setters_mutateFields() {
// @Data genere les setters : verifier la mutabilite attendue.
ConversationMessage msg = new ConversationMessage();
msg.setRole("system");
msg.setContent("system prompt");
assertEquals("system", msg.getRole());
assertEquals("system prompt", msg.getContent());
}
}

View File

@@ -0,0 +1,81 @@
package com.loremind.domain.conversationcontext;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires du domaine pour Conversation (agregat).
* Valide :
* - le @Builder.Default sur la liste des messages (jamais null),
* - la preservation des deux modes d'ancrage (Lore XOR Campaign),
* - la preservation du focus entite optionnel (page / arc / chapter / scene).
* <p>
* NB : la contrainte XOR (un seul de loreId/campaignId non-null) est portee
* par ConversationService cote application, pas par le domaine — le test
* verifie donc juste que les deux champs sont exposes independamment.
*/
class ConversationTest {
@Test
void builder_initializesMessagesToEmptyList_whenNotSet() {
Conversation conv = Conversation.builder()
.id("conv-1")
.title("Discussion autour de Thorin")
.loreId("lore-1")
.build();
assertNotNull(conv.getMessages(), "messages ne doit jamais etre null");
assertTrue(conv.getMessages().isEmpty());
}
@Test
void builder_anchorsToLore_withOptionalEntityFocus() {
Conversation conv = Conversation.builder()
.loreId("lore-1")
.entityType("page")
.entityId("page-42")
.build();
assertEquals("lore-1", conv.getLoreId());
assertEquals("page", conv.getEntityType());
assertEquals("page-42", conv.getEntityId());
}
@Test
void builder_anchorsToCampaign_withSceneFocus() {
Conversation conv = Conversation.builder()
.campaignId("camp-1")
.entityType("scene")
.entityId("sc-7")
.build();
assertEquals("camp-1", conv.getCampaignId());
assertEquals("scene", conv.getEntityType());
}
@Test
void builder_preservesProvidedMessages() {
ConversationMessage m1 = ConversationMessage.builder().role("user").content("hello").build();
ConversationMessage m2 = ConversationMessage.builder().role("assistant").content("hi").build();
Conversation conv = Conversation.builder()
.messages(List.of(m1, m2))
.build();
assertEquals(2, conv.getMessages().size());
assertEquals("user", conv.getMessages().get(0).getRole());
}
@Test
void noArgsConstructor_createsConversationWithNullMessages() {
// @NoArgsConstructor bypass le builder → on accepte que messages soit null
// dans ce cas (cas de reconstruction JPA avant hydratation).
Conversation conv = new Conversation();
assertEquals(null, conv.getId());
}
}

View File

@@ -0,0 +1,113 @@
package com.loremind.domain.generationcontext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import org.junit.jupiter.api.Test;
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 unitaires pour CampaignStructuralContext et ses types imbriques.
* Focus sur les annotations @Singular (chapters/scenes/branches/arcs) qui
* permettent une construction incrementale du graphe narratif.
*/
class CampaignStructuralContextTest {
@Test
void builder_constructsFullNarrativeTree() {
BranchHint branch = BranchHint.builder()
.label("si les PJ fuient")
.targetSceneName("La poursuite")
.condition("PJ < moitie des HP")
.build();
SceneSummary scene = SceneSummary.builder()
.name("L'auberge")
.description("Rencontre tendue avec le tavernier")
.illustrationCount(2)
.branch(branch)
.build();
ChapterSummary chapter = ChapterSummary.builder()
.name("L'arrivee")
.description("Les PJ decouvrent la ville")
.scene(scene)
.build();
ArcSummary arc = ArcSummary.builder()
.name("Acte I")
.description("Mise en place")
.illustrationCount(1)
.chapter(chapter)
.build();
CampaignStructuralContext ctx = CampaignStructuralContext.builder()
.campaignName("Les Ombres")
.campaignDescription("Une campagne dark fantasy")
.arc(arc)
.build();
assertEquals("Les Ombres", ctx.getCampaignName());
assertEquals(1, ctx.getArcs().size());
assertEquals(1, ctx.getArcs().get(0).getChapters().size());
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().size());
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().size());
}
// --- BranchHint ---------------------------------------------------------
@Test
void branchHint_preservesAllFields() {
BranchHint b = BranchHint.builder()
.label("combat")
.targetSceneName("La confrontation")
.condition("initiative > 15")
.build();
assertEquals("combat", b.getLabel());
assertEquals("La confrontation", b.getTargetSceneName());
assertEquals("initiative > 15", b.getCondition());
}
@Test
void branchHint_conditionIsOptional() {
BranchHint b = BranchHint.builder()
.label("suite normale")
.targetSceneName("Scene 2")
.build();
assertNull(b.getCondition());
}
// --- illustrationCount --------------------------------------------------
@Test
void illustrationCount_defaultsToZero_onAllSummaryTypes() {
ArcSummary arc = ArcSummary.builder().name("X").build();
ChapterSummary chapter = ChapterSummary.builder().name("X").build();
SceneSummary scene = SceneSummary.builder().name("X").build();
assertEquals(0, arc.getIllustrationCount());
assertEquals(0, chapter.getIllustrationCount());
assertEquals(0, scene.getIllustrationCount());
}
// --- @Singular : accumulation incrementale -----------------------------
@Test
void singular_accumulatesMultipleCalls() {
ArcSummary arc = ArcSummary.builder()
.name("Acte I")
.chapter(ChapterSummary.builder().name("Ch1").build())
.chapter(ChapterSummary.builder().name("Ch2").build())
.chapter(ChapterSummary.builder().name("Ch3").build())
.build();
assertEquals(3, arc.getChapters().size());
assertTrue(arc.getChapters().stream().anyMatch(c -> "Ch2".equals(c.getName())));
}
}

View File

@@ -0,0 +1,36 @@
package com.loremind.domain.generationcontext;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
/**
* Tests unitaires pour le record ChatMessage.
* Role est une chaine libre cote domaine (les roles acceptes "user"/"assistant"/
* "system" sont une convention — le domaine ne l'impose pas, c'est la
* couche application qui valide au besoin).
*/
class ChatMessageTest {
@Test
void accessors_exposeRoleAndContent() {
ChatMessage msg = new ChatMessage("user", "Que se passe-t-il dans la taverne ?");
assertEquals("user", msg.role());
assertEquals("Que se passe-t-il dans la taverne ?", msg.content());
}
@Test
void twoMessages_withSameContent_areEqual() {
ChatMessage a = new ChatMessage("assistant", "Il y a 3 clients au bar.");
ChatMessage b = new ChatMessage("assistant", "Il y a 3 clients au bar.");
assertEquals(a, b);
}
@Test
void twoMessages_differingOnRole_areNotEqual() {
ChatMessage a = new ChatMessage("user", "hello");
ChatMessage b = new ChatMessage("assistant", "hello");
assertNotEquals(a, b);
}
}

View File

@@ -0,0 +1,95 @@
package com.loremind.domain.generationcontext;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Tests unitaires pour ChatRequest (Value Object).
* Valide les combinaisons supportees par le metier :
* - chat Lore pur (loreContext seul),
* - chat Lore focalise (loreContext + pageContext),
* - chat Campagne (campaignContext),
* - chat Campagne focalise (campaignContext + narrativeEntity).
*/
class ChatRequestTest {
private final List<ChatMessage> sampleMessages = List.of(
new ChatMessage("user", "Bonjour")
);
@Test
void buildLoreOnly_leavesCampaignAndEntityNull() {
ChatRequest request = ChatRequest.builder()
.messages(sampleMessages)
.loreContext(LoreStructuralContext.builder()
.loreName("Ithoril")
.loreDescription("Royaume sombre")
.folders(java.util.Map.of())
.build())
.build();
assertEquals(1, request.getMessages().size());
assertNotNull(request.getLoreContext());
assertEquals("Ithoril", request.getLoreContext().getLoreName());
assertNull(request.getPageContext());
assertNull(request.getCampaignContext());
assertNull(request.getNarrativeEntity());
}
@Test
void buildLoreWithPageFocus_hasBothContexts() {
ChatRequest request = ChatRequest.builder()
.messages(sampleMessages)
.loreContext(LoreStructuralContext.builder().folders(java.util.Map.of()).build())
.pageContext(PageContext.builder()
.title("Thorin")
.templateName("PNJ")
.build())
.build();
assertNotNull(request.getLoreContext());
assertNotNull(request.getPageContext());
assertEquals("Thorin", request.getPageContext().getTitle());
}
@Test
void buildCampaignWithNarrativeEntity_hasBothContexts() {
ChatRequest request = ChatRequest.builder()
.messages(sampleMessages)
.campaignContext(CampaignStructuralContext.builder()
.campaignName("Les Ombres")
.campaignDescription("...")
.build())
.narrativeEntity(NarrativeEntityContext.builder()
.entityType("scene")
.title("L'auberge")
.fields(java.util.Map.of("location", "Taverne"))
.build())
.build();
assertNotNull(request.getCampaignContext());
assertNotNull(request.getNarrativeEntity());
assertEquals("scene", request.getNarrativeEntity().getEntityType());
assertNull(request.getLoreContext());
assertNull(request.getPageContext());
}
@Test
void buildMinimal_onlyRequiresMessages() {
// Cas degenere supporte : aucun contexte, juste l'historique.
ChatRequest request = ChatRequest.builder()
.messages(sampleMessages)
.build();
assertEquals(1, request.getMessages().size());
assertNull(request.getLoreContext());
assertNull(request.getPageContext());
assertNull(request.getCampaignContext());
assertNull(request.getNarrativeEntity());
}
}

View File

@@ -0,0 +1,40 @@
package com.loremind.domain.generationcontext;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
/**
* Tests unitaires pour le record ChatUsage.
* Verifie l'immuabilite (acces via accesseurs generes) et l'egalite
* structurelle (equals/hashCode generes par le record).
*/
class ChatUsageTest {
@Test
void accessors_exposeAllComponents() {
ChatUsage usage = new ChatUsage(1200, 3400, 150, 8192);
assertEquals(1200, usage.system());
assertEquals(3400, usage.history());
assertEquals(150, usage.current());
assertEquals(8192, usage.max());
}
@Test
void twoUsages_withSameComponents_areEqual() {
ChatUsage a = new ChatUsage(100, 200, 50, 4096);
ChatUsage b = new ChatUsage(100, 200, 50, 4096);
assertEquals(a, b);
assertEquals(a.hashCode(), b.hashCode());
}
@Test
void twoUsages_differingOnAnyComponent_areNotEqual() {
ChatUsage base = new ChatUsage(100, 200, 50, 4096);
assertNotEquals(base, new ChatUsage(101, 200, 50, 4096));
assertNotEquals(base, new ChatUsage(100, 201, 50, 4096));
assertNotEquals(base, new ChatUsage(100, 200, 51, 4096));
assertNotEquals(base, new ChatUsage(100, 200, 50, 4097));
}
}

View File

@@ -0,0 +1,49 @@
package com.loremind.domain.generationcontext;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
/**
* Tests unitaires pour GenerationContext (Value Object pour la generation one-shot).
* Verifie la construction via builder et l'egalite structurelle.
*/
class GenerationContextTest {
@Test
void builder_preservesAllFields() {
GenerationContext ctx = GenerationContext.builder()
.loreName("Ithoril")
.loreDescription("Royaume sombre")
.folderName("PNJ")
.templateName("Fiche PNJ")
.templateFields(List.of("histoire", "motto", "apparence"))
.pageTitle("Thorin")
.build();
assertEquals("Ithoril", ctx.getLoreName());
assertEquals("PNJ", ctx.getFolderName());
assertEquals("Fiche PNJ", ctx.getTemplateName());
assertEquals(3, ctx.getTemplateFields().size());
assertEquals("Thorin", ctx.getPageTitle());
}
@Test
void twoContexts_withSameFields_areEqual() {
GenerationContext a = GenerationContext.builder()
.loreName("X").pageTitle("A").templateFields(List.of("f1")).build();
GenerationContext b = GenerationContext.builder()
.loreName("X").pageTitle("A").templateFields(List.of("f1")).build();
assertEquals(a, b);
}
@Test
void twoContexts_differingOnPageTitle_areNotEqual() {
GenerationContext a = GenerationContext.builder().pageTitle("A").build();
GenerationContext b = GenerationContext.builder().pageTitle("B").build();
assertNotEquals(a, b);
}
}

View File

@@ -0,0 +1,41 @@
package com.loremind.domain.generationcontext;
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.assertNotEquals;
/**
* Tests unitaires pour le record GenerationResult.
* Structure triviale (map name -> value) — on verifie juste l'acces et
* l'egalite structurelle.
*/
class GenerationResultTest {
@Test
void accessor_exposesValuesMap() {
GenerationResult result = new GenerationResult(Map.of(
"histoire", "Nee sous une etoile rouge...",
"motto", "Jamais genou en terre"
));
assertEquals(2, result.values().size());
assertEquals("Jamais genou en terre", result.values().get("motto"));
}
@Test
void twoResults_withSameMap_areEqual() {
GenerationResult a = new GenerationResult(Map.of("f", "v"));
GenerationResult b = new GenerationResult(Map.of("f", "v"));
assertEquals(a, b);
}
@Test
void twoResults_withDifferentMaps_areNotEqual() {
GenerationResult a = new GenerationResult(Map.of("f", "v1"));
GenerationResult b = new GenerationResult(Map.of("f", "v2"));
assertNotEquals(a, b);
}
}

View File

@@ -0,0 +1,77 @@
package com.loremind.domain.generationcontext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires pour LoreStructuralContext et son type imbrique PageSummary.
* Valide le @Singular de Lombok sur {@code tags} (alimentation incrementale via
* {@code tag(...)} vs initialisation groupee via {@code tags(...)}).
*/
class LoreStructuralContextTest {
@Test
void builder_preservesFoldersAndTags() {
PageSummary pnj = PageSummary.builder()
.title("Thorin")
.templateName("PNJ")
.values(Map.of("histoire", "Nee sous une etoile rouge"))
.tags(List.of("pnj", "allie"))
.relatedPageTitles(List.of("Taverne du Dragon d'Or"))
.build();
LoreStructuralContext ctx = LoreStructuralContext.builder()
.loreName("Ithoril")
.loreDescription("Royaume sombre")
.folders(Map.of("PNJ", List.of(pnj)))
.tag("royaume")
.tag("dark-fantasy")
.build();
assertEquals("Ithoril", ctx.getLoreName());
assertEquals(1, ctx.getFolders().size());
assertEquals(1, ctx.getFolders().get("PNJ").size());
assertEquals(2, ctx.getTags().size(), "@Singular doit accumuler les appels tag()");
assertTrue(ctx.getTags().contains("royaume"));
assertTrue(ctx.getTags().contains("dark-fantasy"));
}
@Test
void emptyFolders_areAllowed() {
// Dossier vide : legitime (ex: dossier "Lieux" cree mais pas encore peuple).
LoreStructuralContext ctx = LoreStructuralContext.builder()
.loreName("Vide")
.loreDescription("")
.folders(Map.of("Lieux", List.of()))
.build();
assertNotNull(ctx.getFolders().get("Lieux"));
assertTrue(ctx.getFolders().get("Lieux").isEmpty());
}
// --- PageSummary --------------------------------------------------------
@Test
void pageSummary_preservesAllFields() {
PageSummary ps = PageSummary.builder()
.title("Le Donjon du Chaos")
.templateName("Lieu")
.values(Map.of("histoire", "Bati il y a 1000 ans..."))
.tags(List.of("donjon", "ancien"))
.relatedPageTitles(List.of("Thorin", "Garde royale"))
.build();
assertEquals("Le Donjon du Chaos", ps.getTitle());
assertEquals("Lieu", ps.getTemplateName());
assertEquals(1, ps.getValues().size());
assertEquals(2, ps.getTags().size());
assertEquals(2, ps.getRelatedPageTitles().size());
}
}

View File

@@ -0,0 +1,59 @@
package com.loremind.domain.generationcontext;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
/**
* Tests unitaires pour NarrativeEntityContext.
* Trois types attendus : "arc", "chapter", "scene" — mais le domaine ne
* restreint pas la chaine (validation cote application layer).
*/
class NarrativeEntityContextTest {
@Test
void builder_preservesAllFields() {
Map<String, String> fields = new LinkedHashMap<>();
fields.put("themes", "trahison");
fields.put("stakes", "la survie du royaume");
NarrativeEntityContext ctx = NarrativeEntityContext.builder()
.entityType("arc")
.title("Acte I")
.fields(fields)
.build();
assertEquals("arc", ctx.getEntityType());
assertEquals("Acte I", ctx.getTitle());
assertEquals(2, ctx.getFields().size());
assertEquals("trahison", ctx.getFields().get("themes"));
}
@Test
void fieldsOrder_isPreserved_whenUsingLinkedHashMap() {
// L'ordre des champs est significatif : le prompt doit etre lisible.
Map<String, String> fields = new LinkedHashMap<>();
fields.put("location", "Taverne");
fields.put("timing", "Soir");
fields.put("atmosphere", "fumee");
NarrativeEntityContext ctx = NarrativeEntityContext.builder()
.entityType("scene")
.title("L'auberge")
.fields(fields)
.build();
assertEquals("[location, timing, atmosphere]", ctx.getFields().keySet().toString());
}
@Test
void twoContexts_differingOnEntityType_areNotEqual() {
NarrativeEntityContext a = NarrativeEntityContext.builder().entityType("arc").title("X").build();
NarrativeEntityContext b = NarrativeEntityContext.builder().entityType("scene").title("X").build();
assertNotEquals(a, b);
}
}

View File

@@ -0,0 +1,44 @@
package com.loremind.domain.generationcontext;
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.assertTrue;
/**
* Tests unitaires pour PageContext (Value Object : focus IA sur une page).
*/
class PageContextTest {
@Test
void builder_preservesAllFields() {
PageContext ctx = PageContext.builder()
.title("Thorin")
.templateName("PNJ")
.templateFields(List.of("histoire", "apparence", "motto"))
.values(Map.of("histoire", "Nee sous une etoile rouge"))
.build();
assertEquals("Thorin", ctx.getTitle());
assertEquals("PNJ", ctx.getTemplateName());
assertEquals(3, ctx.getTemplateFields().size());
assertEquals(1, ctx.getValues().size());
}
@Test
void emptyValues_areAllowed() {
// Page vierge : template defini mais aucun champ rempli (cas generation ex-nihilo).
PageContext ctx = PageContext.builder()
.title("Nouveau PNJ")
.templateName("PNJ")
.templateFields(List.of("histoire", "apparence"))
.values(Map.of())
.build();
assertTrue(ctx.getValues().isEmpty());
assertEquals(2, ctx.getTemplateFields().size());
}
}

View File

@@ -0,0 +1,45 @@
package com.loremind.domain.images;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests unitaires du domaine pour Image (Shared Kernel).
* Entite pure : metadata + cle opaque vers l'object storage.
* On verifie juste la preservation des champs — aucune logique metier.
*/
class ImageTest {
@Test
void builder_preservesAllFields() {
LocalDateTime now = LocalDateTime.now();
Image image = Image.builder()
.id("img-1")
.filename("portrait-elfe.jpg")
.contentType("image/jpeg")
.sizeBytes(125_000L)
.storageKey("images/abc123.jpg")
.uploadedAt(now)
.build();
assertEquals("img-1", image.getId());
assertEquals("portrait-elfe.jpg", image.getFilename());
assertEquals("image/jpeg", image.getContentType());
assertEquals(125_000L, image.getSizeBytes());
assertEquals("images/abc123.jpg", image.getStorageKey());
assertEquals(now, image.getUploadedAt());
}
@Test
void builder_supportsCommonMimeTypes() {
// Verifie que n'importe quelle chaine MIME passe : la validation se fait
// cote application (ImageService) pas dans le domaine.
for (String mime : new String[]{"image/jpeg", "image/png", "image/webp", "image/gif"}) {
Image image = Image.builder().contentType(mime).build();
assertEquals(mime, image.getContentType());
}
}
}

View File

@@ -0,0 +1,42 @@
package com.loremind.domain.lorecontext;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Tests unitaires du domaine pour LoreNode.
* Verifie la preservation des champs et l'absence de parent (racine de l'arbre).
*/
class LoreNodeTest {
@Test
void builder_preservesAllFields() {
LoreNode node = LoreNode.builder()
.id("n-1")
.name("Personnages")
.icon("users")
.parentId("n-root")
.loreId("lore-1")
.build();
assertEquals("n-1", node.getId());
assertEquals("Personnages", node.getName());
assertEquals("users", node.getIcon());
assertEquals("n-root", node.getParentId());
assertEquals("lore-1", node.getLoreId());
}
@Test
void parentId_isNull_forRootNodes() {
// Un node racine a parentId == null : invariant de l'arborescence.
LoreNode root = LoreNode.builder()
.id("n-root")
.name("Racine")
.loreId("lore-1")
.build();
assertNull(root.getParentId());
}
}

View File

@@ -0,0 +1,39 @@
package com.loremind.domain.lorecontext;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* Tests unitaires du domaine pour Lore.
* Entite racine du Bounded Context Lore — POJO pur, on verifie juste la
* preservation des champs par le builder.
*/
class LoreTest {
@Test
void builder_preservesAllFields() {
Lore lore = Lore.builder()
.id("lore-1")
.name("Ithoril")
.description("Royaume en decadence apres la guerre des eclipses.")
.nodeCount(12)
.pageCount(57)
.build();
assertEquals("lore-1", lore.getId());
assertEquals("Ithoril", lore.getName());
assertEquals("Royaume en decadence apres la guerre des eclipses.", lore.getDescription());
assertEquals(12, lore.getNodeCount());
assertEquals(57, lore.getPageCount());
}
@Test
void builder_allowsNoArgs() {
Lore lore = Lore.builder().build();
assertNotNull(lore);
assertEquals(0, lore.getNodeCount());
assertEquals(0, lore.getPageCount());
}
}

View File

@@ -0,0 +1,69 @@
package com.loremind.domain.lorecontext;
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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires du domaine pour Page.
* Valide :
* - la methode metier {@code hasTemplate()} (null / blank / valide),
* - la preservation des deux maps de valeurs (TEXT vs IMAGE),
* - la preservation des metadonnees editoriales (notes, tags, relatedPageIds).
*/
class PageTest {
@Test
void hasTemplate_returnsFalse_whenTemplateIdIsNull() {
Page page = Page.builder().templateId(null).build();
assertFalse(page.hasTemplate());
}
@Test
void hasTemplate_returnsFalse_whenTemplateIdIsBlank() {
Page page = Page.builder().templateId(" ").build();
assertFalse(page.hasTemplate());
}
@Test
void hasTemplate_returnsTrue_whenTemplateIdIsPresent() {
Page page = Page.builder().templateId("tpl-1").build();
assertTrue(page.hasTemplate());
}
@Test
void builder_preservesTextAndImageValuesSeparately() {
Page page = Page.builder()
.values(Map.of("histoire", "Nee sous une etoile rouge...", "motto", "Jamais genou en terre"))
.imageValues(Map.of(
"portraits", List.of("img-1", "img-2"),
"cartes", List.of("img-3")
))
.build();
assertEquals(2, page.getValues().size());
assertEquals("Nee sous une etoile rouge...", page.getValues().get("histoire"));
assertEquals(2, page.getImageValues().size());
assertEquals(2, page.getImageValues().get("portraits").size());
assertEquals("img-3", page.getImageValues().get("cartes").get(0));
}
@Test
void builder_preservesEditorialMetadata() {
Page page = Page.builder()
.notes("secret MJ : trahison a venir")
.tags(List.of("pnj", "faction-ombre"))
.relatedPageIds(List.of("page-a", "page-b"))
.build();
assertEquals("secret MJ : trahison a venir", page.getNotes());
assertEquals(2, page.getTags().size());
assertTrue(page.getTags().contains("faction-ombre"));
assertEquals(2, page.getRelatedPageIds().size());
}
}

View File

@@ -0,0 +1,68 @@
package com.loremind.domain.lorecontext;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Tests unitaires du domaine pour TemplateField.
* Valide les fabriques statiques (text/image/image-with-layout) et le
* constructeur de retrocompat a 2 arguments.
*/
class TemplateFieldTest {
// --- Factory : text ----------------------------------------------------
@Test
void text_createsTextFieldWithoutLayout() {
TemplateField field = TemplateField.text("histoire");
assertEquals("histoire", field.getName());
assertEquals(FieldType.TEXT, field.getType());
assertNull(field.getLayout(), "layout doit etre null pour un champ TEXT");
}
// --- Factory : image ---------------------------------------------------
@Test
void image_createsImageFieldWithDefaultGalleryLayout() {
TemplateField field = TemplateField.image("portraits");
assertEquals("portraits", field.getName());
assertEquals(FieldType.IMAGE, field.getType());
assertEquals(ImageLayout.GALLERY, field.getLayout(), "image(name) doit utiliser GALLERY par defaut");
}
@Test
void image_createsImageFieldWithCustomLayout() {
TemplateField field = TemplateField.image("banniere", ImageLayout.HERO);
assertEquals(FieldType.IMAGE, field.getType());
assertEquals(ImageLayout.HERO, field.getLayout());
}
// --- Constructeur retrocompat (2 args) ---------------------------------
@Test
void twoArgsConstructor_leavesLayoutNull() {
// Constructeur legacy (name, type) — garde la compat avec le code anterieur
// a l'ajout du champ `layout`.
TemplateField field = new TemplateField("nom", FieldType.TEXT);
assertEquals("nom", field.getName());
assertEquals(FieldType.TEXT, field.getType());
assertNull(field.getLayout());
}
// --- Builder ------------------------------------------------------------
@Test
void builder_allowsFullCustomization() {
TemplateField field = TemplateField.builder()
.name("galerie-moodboard")
.type(FieldType.IMAGE)
.layout(ImageLayout.MASONRY)
.build();
assertEquals("galerie-moodboard", field.getName());
assertEquals(FieldType.IMAGE, field.getType());
assertEquals(ImageLayout.MASONRY, field.getLayout());
}
}

View File

@@ -0,0 +1,86 @@
package com.loremind.domain.lorecontext;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires du domaine pour Template.
* Focus sur les deux methodes metier : {@code fieldCount()} et
* {@code textFieldNames()} — cette derniere est critique car c'est elle qui
* pilote ce qui est envoye a l'IA pour generation (seuls les champs TEXT).
*/
class TemplateTest {
// --- fieldCount ---------------------------------------------------------
@Test
void fieldCount_returnsZero_whenFieldsIsNull() {
Template tpl = Template.builder().fields(null).build();
assertEquals(0, tpl.fieldCount());
}
@Test
void fieldCount_returnsZero_whenFieldsIsEmpty() {
Template tpl = Template.builder().fields(List.of()).build();
assertEquals(0, tpl.fieldCount());
}
@Test
void fieldCount_countsAllFieldsRegardlessOfType() {
Template tpl = Template.builder()
.fields(List.of(
TemplateField.text("histoire"),
TemplateField.text("famille"),
TemplateField.image("portraits")
))
.build();
assertEquals(3, tpl.fieldCount());
}
// --- textFieldNames : filtrage critique pour la generation IA -----------
@Test
void textFieldNames_returnsEmptyList_whenFieldsIsNull() {
Template tpl = Template.builder().fields(null).build();
assertTrue(tpl.textFieldNames().isEmpty());
}
@Test
void textFieldNames_excludesImageFields() {
// L'IA ne doit JAMAIS recevoir les champs IMAGE comme cibles de generation.
Template tpl = Template.builder()
.fields(List.of(
TemplateField.text("histoire"),
TemplateField.image("portraits"),
TemplateField.text("motto"),
TemplateField.image("cartes", ImageLayout.HERO)
))
.build();
List<String> names = tpl.textFieldNames();
assertEquals(2, names.size());
assertTrue(names.contains("histoire"));
assertTrue(names.contains("motto"));
assertTrue(!names.contains("portraits"));
assertTrue(!names.contains("cartes"));
}
@Test
void textFieldNames_preservesOrder() {
// L'ordre des champs est significatif dans l'UI et dans le prompt IA.
Template tpl = Template.builder()
.fields(List.of(
TemplateField.text("zebre"),
TemplateField.text("alpha"),
TemplateField.text("mousse")
))
.build();
assertEquals(List.of("zebre", "alpha", "mousse"), tpl.textFieldNames());
}
}

View File

@@ -0,0 +1,82 @@
package com.loremind.domain.shared;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires pour CollectionUtils (copies defensives).
* Classe simple mais critique : un oubli de copie peut laisser fuiter des
* references mutables dans le domaine, ce qui casse l'immuabilite attendue
* et ouvre la porte a des mutations a distance.
*/
class CollectionUtilsTest {
// --- copyMap ------------------------------------------------------------
@Test
void copyMap_returnsEmptyMap_whenSourceIsNull() {
Map<String, String> copy = CollectionUtils.copyMap(null);
assertTrue(copy.isEmpty());
}
@Test
void copyMap_returnsDefensiveCopy_distinctFromSource() {
Map<String, Integer> source = Map.of("a", 1, "b", 2);
Map<String, Integer> copy = CollectionUtils.copyMap(source);
assertEquals(source, copy);
assertNotSame(source, copy, "La copie doit etre un objet distinct");
}
@Test
void copyMap_mutatingCopyDoesNotAffectSource() {
Map<String, Integer> source = new HashMap<>();
source.put("a", 1);
Map<String, Integer> copy = CollectionUtils.copyMap(source);
copy.put("b", 2);
assertEquals(1, source.size(), "La mutation de la copie ne doit pas fuiter sur la source");
assertEquals(2, copy.size());
}
// --- copyList -----------------------------------------------------------
@Test
void copyList_returnsEmptyList_whenSourceIsNull() {
List<String> copy = CollectionUtils.copyList(null);
assertTrue(copy.isEmpty());
}
@Test
void copyList_returnsDefensiveCopy_distinctFromSource() {
List<String> source = List.of("x", "y", "z");
List<String> copy = CollectionUtils.copyList(source);
assertEquals(source, copy);
assertNotSame(source, copy);
}
@Test
void copyList_mutatingCopyDoesNotAffectSource() {
List<String> source = new java.util.ArrayList<>(List.of("a"));
List<String> copy = CollectionUtils.copyList(source);
copy.add("b");
assertEquals(1, source.size());
assertEquals(2, copy.size());
}
@Test
void copyList_preservesOrder() {
List<String> source = List.of("zebre", "alpha", "mousse");
List<String> copy = CollectionUtils.copyList(source);
assertEquals(List.of("zebre", "alpha", "mousse"), copy);
}
}

View File

@@ -0,0 +1,315 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.PageContext;
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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires pour BrainChatPayloadBuilder.
* Verifie la traduction ChatRequest (domaine) -> dict JSON (schema Brain).
* Points critiques :
* - omission conditionnelle des contextes null (alignement Pydantic Optional),
* - omission conditionnelle des sous-champs vides (values/tags/branches),
* - sérialisation récursive arc -> chapter -> scene -> branches.
*/
class BrainChatPayloadBuilderTest {
private final BrainChatPayloadBuilder builder = new BrainChatPayloadBuilder();
private final List<ChatMessage> sampleMessages = List.of(
new ChatMessage("user", "Bonjour"),
new ChatMessage("assistant", "Salut"));
/** Helper : cast generique d'un Object vers Map<String,Object>. Evite les chaines de casts illisibles. */
@SuppressWarnings("unchecked")
private static Map<String, Object> asMap(Object o) {
return (Map<String, Object>) o;
}
/** Helper : recupere le premier element d'une liste-de-maps imbriquee sous une cle. */
@SuppressWarnings("unchecked")
private static Map<String, Object> firstOf(Map<String, Object> parent, String key) {
return ((List<Map<String, Object>>) parent.get(key)).get(0);
}
// ---------- messages + omission des contextes null ---------------------
@Test
void build_withMessagesOnly_omitsAllOptionalContexts() {
ChatRequest req = ChatRequest.builder().messages(sampleMessages).build();
Map<String, Object> payload = builder.build(req);
assertTrue(payload.containsKey("messages"));
assertFalse(payload.containsKey("lore_context"));
assertFalse(payload.containsKey("page_context"));
assertFalse(payload.containsKey("campaign_context"));
assertFalse(payload.containsKey("narrative_entity"));
}
@Test
@SuppressWarnings("unchecked")
void build_messagesSerialization_preservesRoleAndContent() {
ChatRequest req = ChatRequest.builder().messages(sampleMessages).build();
Map<String, Object> payload = builder.build(req);
List<Map<String, Object>> messages = (List<Map<String, Object>>) payload.get("messages");
assertEquals(2, messages.size());
assertEquals("user", messages.get(0).get("role"));
assertEquals("Bonjour", messages.get(0).get("content"));
assertEquals("assistant", messages.get(1).get("role"));
}
// ---------- lore_context + page_summary omissions ----------------------
@Test
@SuppressWarnings("unchecked")
void build_loreContext_includesBasicFields() {
LoreStructuralContext lore = LoreStructuralContext.builder()
.loreName("Ithoril")
.loreDescription("Royaume sombre")
.folders(Map.of())
.tag("dark-fantasy")
.build();
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
Map<String, Object> payload = builder.build(req);
Map<String, Object> lctx = (Map<String, Object>) payload.get("lore_context");
assertEquals("Ithoril", lctx.get("lore_name"));
assertEquals("Royaume sombre", lctx.get("lore_description"));
assertNotNull(lctx.get("folders"));
assertEquals(List.of("dark-fantasy"), lctx.get("tags"));
}
@Test
@SuppressWarnings("unchecked")
void build_pageSummary_omitsEmptyValuesTagsAndRelated() {
PageSummary minimal = PageSummary.builder()
.title("Thorin")
.templateName("PNJ")
.values(Map.of())
.tags(List.of())
.relatedPageTitles(List.of())
.build();
LoreStructuralContext lore = LoreStructuralContext.builder()
.loreName("X").loreDescription("")
.folders(Map.of("PNJ", List.of(minimal)))
.build();
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
Map<String, Object> payload = builder.build(req);
Map<String, Object> lctx = (Map<String, Object>) payload.get("lore_context");
Map<String, List<Map<String, Object>>> folders = (Map<String, List<Map<String, Object>>>) lctx.get("folders");
Map<String, Object> page = folders.get("PNJ").get(0);
assertEquals("Thorin", page.get("title"));
assertEquals("PNJ", page.get("template_name"));
// Omissions : sous-champs vides absents du payload (allege le prompt).
assertFalse(page.containsKey("values"));
assertFalse(page.containsKey("tags"));
assertFalse(page.containsKey("related_page_titles"));
}
@Test
@SuppressWarnings("unchecked")
void build_pageSummary_includesNonEmptyValuesTagsAndRelated() {
PageSummary full = PageSummary.builder()
.title("Thorin")
.templateName("PNJ")
.values(Map.of("histoire", "Nee sous une etoile rouge"))
.tags(List.of("pnj", "allie"))
.relatedPageTitles(List.of("Taverne du Dragon d'Or"))
.build();
LoreStructuralContext lore = LoreStructuralContext.builder()
.loreName("X").loreDescription("")
.folders(Map.of("PNJ", List.of(full)))
.build();
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
Map<String, Object> payload = builder.build(req);
Map<String, Object> lctx = (Map<String, Object>) payload.get("lore_context");
Map<String, List<Map<String, Object>>> folders = (Map<String, List<Map<String, Object>>>) lctx.get("folders");
Map<String, Object> page = folders.get("PNJ").get(0);
assertTrue(page.containsKey("values"));
assertTrue(page.containsKey("tags"));
assertTrue(page.containsKey("related_page_titles"));
assertEquals(List.of("pnj", "allie"), page.get("tags"));
}
// ---------- page_context -----------------------------------------------
@Test
@SuppressWarnings("unchecked")
void build_pageContext_includesAllFields() {
PageContext pc = PageContext.builder()
.title("Thorin")
.templateName("PNJ")
.templateFields(List.of("histoire", "motto"))
.values(Map.of("histoire", "..."))
.build();
ChatRequest req = ChatRequest.builder().messages(sampleMessages).pageContext(pc).build();
Map<String, Object> payload = builder.build(req);
Map<String, Object> pctx = (Map<String, Object>) payload.get("page_context");
assertEquals("Thorin", pctx.get("title"));
assertEquals("PNJ", pctx.get("template_name"));
assertEquals(List.of("histoire", "motto"), pctx.get("template_fields"));
assertEquals(Map.of("histoire", "..."), pctx.get("values"));
}
// ---------- campaign_context + arc/chapter/scene recursion -------------
@Test
@SuppressWarnings("unchecked")
void build_campaignContext_serializesFullNarrativeTree() {
BranchHint branch = BranchHint.builder()
.label("fuite").targetSceneName("La poursuite").condition("HP < 50%").build();
SceneSummary scene = SceneSummary.builder()
.name("L'auberge").description("Rencontre tendue")
.illustrationCount(3).branch(branch).build();
ChapterSummary chapter = ChapterSummary.builder()
.name("L'arrivee").description("...").scene(scene).build();
ArcSummary arc = ArcSummary.builder()
.name("Acte I").description("Mise en place").illustrationCount(1).chapter(chapter).build();
CampaignStructuralContext camp = CampaignStructuralContext.builder()
.campaignName("Les Ombres").campaignDescription("dark fantasy").arc(arc).build();
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req);
Map<String, Object> cctx = (Map<String, Object>) payload.get("campaign_context");
assertEquals("Les Ombres", cctx.get("campaign_name"));
List<Map<String, Object>> arcs = (List<Map<String, Object>>) cctx.get("arcs");
Map<String, Object> arcMap = arcs.get(0);
assertEquals("Acte I", arcMap.get("name"));
assertEquals(1, arcMap.get("illustration_count"));
List<Map<String, Object>> chapters = (List<Map<String, Object>>) arcMap.get("chapters");
Map<String, Object> chapterMap = chapters.get(0);
assertEquals("L'arrivee", chapterMap.get("name"));
List<Map<String, Object>> scenes = (List<Map<String, Object>>) chapterMap.get("scenes");
Map<String, Object> sceneMap = scenes.get(0);
assertEquals("L'auberge", sceneMap.get("name"));
assertEquals(3, sceneMap.get("illustration_count"));
List<Map<String, Object>> branches = (List<Map<String, Object>>) sceneMap.get("branches");
Map<String, Object> branchMap = branches.get(0);
assertEquals("fuite", branchMap.get("label"));
assertEquals("La poursuite", branchMap.get("target_scene_name"));
assertEquals("HP < 50%", branchMap.get("condition"));
}
@Test
@SuppressWarnings("unchecked")
void build_arcSummary_omitsIllustrationCount_whenZero() {
ArcSummary arc = ArcSummary.builder().name("A").description("").illustrationCount(0).build();
CampaignStructuralContext camp = CampaignStructuralContext.builder()
.campaignName("X").campaignDescription("").arc(arc).build();
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req);
Map<String, Object> arcMap = firstOf(asMap(payload.get("campaign_context")), "arcs");
// Economie de payload : n'injecte pas "N illustrations" quand N=0.
assertFalse(arcMap.containsKey("illustration_count"));
}
@Test
@SuppressWarnings("unchecked")
void build_sceneSummary_omitsBranches_whenEmpty() {
SceneSummary scene = SceneSummary.builder().name("S").description("").build();
ChapterSummary chapter = ChapterSummary.builder().name("Ch").description("").scene(scene).build();
ArcSummary arc = ArcSummary.builder().name("A").description("").chapter(chapter).build();
CampaignStructuralContext camp = CampaignStructuralContext.builder()
.campaignName("X").campaignDescription("").arc(arc).build();
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req);
Map<String, Object> arcMap = firstOf(asMap(payload.get("campaign_context")), "arcs");
Map<String, Object> chapterMap = firstOf(arcMap, "chapters");
Map<String, Object> sceneMap = firstOf(chapterMap, "scenes");
assertFalse(sceneMap.containsKey("branches"));
}
@Test
@SuppressWarnings("unchecked")
void build_branchHint_omitsCondition_whenBlank() {
BranchHint branch = BranchHint.builder().label("X").targetSceneName("Y").condition(" ").build();
SceneSummary scene = SceneSummary.builder().name("S").description("").branch(branch).build();
ChapterSummary chapter = ChapterSummary.builder().name("Ch").description("").scene(scene).build();
ArcSummary arc = ArcSummary.builder().name("A").description("").chapter(chapter).build();
CampaignStructuralContext camp = CampaignStructuralContext.builder()
.campaignName("X").campaignDescription("").arc(arc).build();
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req);
Map<String, Object> arcMap = firstOf(asMap(payload.get("campaign_context")), "arcs");
Map<String, Object> chapterMap = firstOf(arcMap, "chapters");
Map<String, Object> sceneMap = firstOf(chapterMap, "scenes");
Map<String, Object> branchMap = firstOf(sceneMap, "branches");
assertFalse(branchMap.containsKey("condition"));
}
// ---------- narrative_entity -------------------------------------------
@Test
@SuppressWarnings("unchecked")
void build_narrativeEntity_includesAllFields() {
NarrativeEntityContext entity = NarrativeEntityContext.builder()
.entityType("scene").title("L'auberge")
.fields(Map.of("location", "Taverne", "timing", "Soir"))
.build();
ChatRequest req = ChatRequest.builder().messages(sampleMessages).narrativeEntity(entity).build();
Map<String, Object> payload = builder.build(req);
Map<String, Object> ne = (Map<String, Object>) payload.get("narrative_entity");
assertEquals("scene", ne.get("entity_type"));
assertEquals("L'auberge", ne.get("title"));
assertEquals(2, ((Map<?, ?>) ne.get("fields")).size());
}
// ---------- combinaison complete ---------------------------------------
@Test
void build_campaignScenario_includesBothContextsAndEntity() {
CampaignStructuralContext camp = CampaignStructuralContext.builder()
.campaignName("X").campaignDescription("").build();
NarrativeEntityContext entity = NarrativeEntityContext.builder()
.entityType("arc").title("T").fields(Map.of()).build();
ChatRequest req = ChatRequest.builder()
.messages(sampleMessages)
.campaignContext(camp)
.narrativeEntity(entity)
.build();
Map<String, Object> payload = builder.build(req);
assertTrue(payload.containsKey("campaign_context"));
assertTrue(payload.containsKey("narrative_entity"));
assertFalse(payload.containsKey("lore_context"));
assertFalse(payload.containsKey("page_context"));
}
}

View File

@@ -0,0 +1,119 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.generationcontext.ChatUsage;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Tests unitaires pour BrainSseParser.
* Parser minimaliste (sans Jackson) : on verifie les cas nominaux et
* TOUS les edge cases — c'est precisement ce genre de code artisanal
* qui casse silencieusement si on n'a pas de tests.
*/
class BrainSseParserTest {
private final BrainSseParser parser = new BrainSseParser();
// ---------- parseUsage --------------------------------------------------
@Test
void parseUsage_parsesCompletePayload() {
String json = "{\"system\":1200,\"history\":3400,\"current\":150,\"max\":8192}";
ChatUsage usage = parser.parseUsage(json);
assertNotNull(usage);
assertEquals(1200, usage.system());
assertEquals(3400, usage.history());
assertEquals(150, usage.current());
assertEquals(8192, usage.max());
}
@Test
void parseUsage_returnsNull_whenJsonIsNull() {
assertNull(parser.parseUsage(null));
}
@Test
void parseUsage_treatsMissingFieldAsZero() {
// Un champ manquant ne doit pas planter : l'extractIntField renvoie 0.
String json = "{\"system\":100,\"history\":200}";
ChatUsage usage = parser.parseUsage(json);
assertNotNull(usage);
assertEquals(100, usage.system());
assertEquals(200, usage.history());
assertEquals(0, usage.current());
assertEquals(0, usage.max());
}
@Test
void parseUsage_supportsNegativeValues() {
// L'API ne devrait jamais envoyer de negatifs mais le parser ne doit
// pas les confondre avec du JSON invalide.
String json = "{\"system\":-1,\"history\":0,\"current\":0,\"max\":0}";
ChatUsage usage = parser.parseUsage(json);
assertEquals(-1, usage.system());
}
@Test
void parseUsage_toleratesWhitespaceAroundColon() {
String json = "{\"system\" : 100, \"history\":200,\"current\":50,\"max\":4096}";
ChatUsage usage = parser.parseUsage(json);
assertEquals(100, usage.system());
assertEquals(200, usage.history());
}
@Test
void parseUsage_treatsNonIntegerFieldAsZero() {
// Comportement defensif : le parser scanne caractere par caractere et
// s'arrete des qu'il ne voit plus de chiffre. Pour un champ contenant
// une chaine (ex: "abc"), il ne lit aucun chiffre -> renvoie 0. Pas
// d'exception propagee : le chat continue, la jauge affiche juste 0.
String json = "{\"system\":\"abc\",\"history\":0,\"current\":0,\"max\":0}";
ChatUsage usage = parser.parseUsage(json);
assertNotNull(usage);
assertEquals(0, usage.system());
}
// ---------- parseToken --------------------------------------------------
@Test
void parseToken_extractsSimpleToken() {
assertEquals("hello", parser.parseToken("{\"token\":\"hello\"}"));
}
@Test
void parseToken_returnsNull_whenJsonIsNull() {
assertNull(parser.parseToken(null));
}
@Test
void parseToken_returnsNull_whenTokenFieldMissing() {
assertNull(parser.parseToken("{\"other\":\"value\"}"));
}
@Test
void parseToken_unescapesNewlines() {
assertEquals("line1\nline2", parser.parseToken("{\"token\":\"line1\\nline2\"}"));
}
@Test
void parseToken_unescapesDoubleQuotes() {
// Attention : lastIndexOf('"') trouve le guillemet fermant final du JSON,
// donc les guillemets echappes internes sont bien inclus dans la valeur.
assertEquals("il dit \"salut\"", parser.parseToken("{\"token\":\"il dit \\\"salut\\\"\"}"));
}
@Test
void parseToken_unescapesBackslash() {
assertEquals("path\\file", parser.parseToken("{\"token\":\"path\\\\file\"}"));
}
@Test
void parseToken_handlesEmptyStringToken() {
assertEquals("", parser.parseToken("{\"token\":\"\"}"));
}
}

View File

@@ -0,0 +1,67 @@
package com.loremind.infrastructure.persistence.converter;
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.assertThrows;
/**
* Tests pour MapJsonConverter (Map<String,Object> generique).
* ATTENTION : contrairement aux autres converters, celui-ci renvoie null pour
* null (pas "{}"), et "autoApply=false" ne s'applique qu'aux champs annotes
* explicitement. Design historique — les tests documentent cette specificite.
*/
class MapJsonConverterTest {
private final MapJsonConverter converter = new MapJsonConverter();
@Test
void toDb_nullMap_returnsNull() {
assertNull(converter.convertToDatabaseColumn(null));
}
@Test
void toDb_emptyMap_returnsEmptyJsonObject() {
assertEquals("{}", converter.convertToDatabaseColumn(Map.of()));
}
@Test
void toDb_populatedMap_returnsJson() {
String json = converter.convertToDatabaseColumn(Map.of("n", 42));
assertEquals("{\"n\":42}", json);
}
@Test
void fromDb_nullString_returnsNull() {
assertNull(converter.convertToEntityAttribute(null));
}
@Test
void fromDb_emptyJsonObject_returnsEmptyMap() {
assertEquals(Map.of(), converter.convertToEntityAttribute("{}"));
}
@Test
void fromDb_populatedJson_returnsMap() {
Map<String, Object> result = converter.convertToEntityAttribute("{\"age\":42,\"nom\":\"Thorin\"}");
assertEquals(2, result.size());
assertEquals(42, result.get("age"));
assertEquals("Thorin", result.get("nom"));
}
@Test
void fromDb_malformedJson_throwsIllegalArgumentException() {
assertThrows(IllegalArgumentException.class,
() -> converter.convertToEntityAttribute("not json"));
}
@Test
void roundTrip_preservesValues() {
Map<String, Object> source = Map.of("s", "hello", "n", 7);
String json = converter.convertToDatabaseColumn(source);
assertEquals(source, converter.convertToEntityAttribute(json));
}
}

View File

@@ -0,0 +1,74 @@
package com.loremind.infrastructure.persistence.converter;
import com.loremind.domain.campaigncontext.SceneBranch;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests pour SceneBranchListJsonConverter.
* SceneBranch est immuable (@Value + @Jacksonized), donc Jackson utilise le
* builder pour la deserialisation. Le round-trip est le test critique :
* il casserait silencieusement si quelqu'un retirait @Jacksonized.
*/
class SceneBranchListJsonConverterTest {
private final SceneBranchListJsonConverter converter = new SceneBranchListJsonConverter();
@Test
void toDb_nullList_yieldsEmptyJsonArray() {
assertEquals("[]", converter.convertToDatabaseColumn(null));
}
@Test
void toDb_emptyList_yieldsEmptyJsonArray() {
assertEquals("[]", converter.convertToDatabaseColumn(List.of()));
}
@Test
void fromDb_nullString_yieldsEmptyList() {
assertTrue(converter.convertToEntityAttribute(null).isEmpty());
}
@Test
void fromDb_blankString_yieldsEmptyList() {
assertTrue(converter.convertToEntityAttribute(" ").isEmpty());
}
@Test
void fromDb_malformedJson_throwsIllegalStateException() {
assertThrows(IllegalStateException.class,
() -> converter.convertToEntityAttribute("not json"));
}
@Test
void roundTrip_preservesAllBranchFields() {
// Test critique : depend de @Jacksonized sur SceneBranch.
List<SceneBranch> source = List.of(
SceneBranch.builder()
.label("si les joueurs attaquent")
.targetSceneId("sc-combat")
.condition("initiative > 15")
.build(),
SceneBranch.builder()
.label("si les joueurs fuient")
.targetSceneId("sc-poursuite")
.build()
);
String json = converter.convertToDatabaseColumn(source);
List<SceneBranch> back = converter.convertToEntityAttribute(json);
assertEquals(2, back.size());
assertEquals("si les joueurs attaquent", back.get(0).getLabel());
assertEquals("sc-combat", back.get(0).getTargetSceneId());
assertEquals("initiative > 15", back.get(0).getCondition());
assertEquals("sc-poursuite", back.get(1).getTargetSceneId());
assertNull(back.get(1).getCondition(), "condition absente doit rester null apres round-trip");
}
}

View File

@@ -0,0 +1,79 @@
package com.loremind.infrastructure.persistence.converter;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests pour StringListJsonConverter (JPA AttributeConverter).
* Convention : null/vide -> "[]" en DB, DB null/blank -> liste vide en entite.
*/
class StringListJsonConverterTest {
private final StringListJsonConverter converter = new StringListJsonConverter();
// ---------- convertToDatabaseColumn ------------------------------------
@Test
void toDb_nullList_yieldsEmptyJsonArray() {
assertEquals("[]", converter.convertToDatabaseColumn(null));
}
@Test
void toDb_emptyList_yieldsEmptyJsonArray() {
assertEquals("[]", converter.convertToDatabaseColumn(List.of()));
}
@Test
void toDb_populatedList_yieldsJsonArray() {
assertEquals("[\"a\",\"b\",\"c\"]",
converter.convertToDatabaseColumn(List.of("a", "b", "c")));
}
@Test
void toDb_preservesOrder() {
assertEquals("[\"zebre\",\"alpha\",\"mousse\"]",
converter.convertToDatabaseColumn(List.of("zebre", "alpha", "mousse")));
}
// ---------- convertToEntityAttribute -----------------------------------
@Test
void fromDb_nullString_yieldsEmptyList() {
assertTrue(converter.convertToEntityAttribute(null).isEmpty());
}
@Test
void fromDb_blankString_yieldsEmptyList() {
assertTrue(converter.convertToEntityAttribute(" ").isEmpty());
}
@Test
void fromDb_emptyJsonArray_yieldsEmptyList() {
assertTrue(converter.convertToEntityAttribute("[]").isEmpty());
}
@Test
void fromDb_populatedJsonArray_yieldsList() {
assertEquals(List.of("x", "y"), converter.convertToEntityAttribute("[\"x\",\"y\"]"));
}
@Test
void fromDb_malformedJson_throwsIllegalStateException() {
assertThrows(IllegalStateException.class,
() -> converter.convertToEntityAttribute("not a json"));
}
// ---------- Round-trip --------------------------------------------------
@Test
void roundTrip_preservesAllEntries() {
List<String> source = List.of("pnj", "allie", "royaume");
String json = converter.convertToDatabaseColumn(source);
assertEquals(source, converter.convertToEntityAttribute(json));
}
}

View File

@@ -0,0 +1,68 @@
package com.loremind.infrastructure.persistence.converter;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests pour StringListMapJsonConverter (Page.imageValues).
* Structure : Map<String, List<String>> — pour chaque champ IMAGE, la liste
* ordonnee des IDs d'images attachees.
*/
class StringListMapJsonConverterTest {
private final StringListMapJsonConverter converter = new StringListMapJsonConverter();
@Test
void toDb_nullMap_yieldsEmptyJsonObject() {
assertEquals("{}", converter.convertToDatabaseColumn(null));
}
@Test
void toDb_emptyMap_yieldsEmptyJsonObject() {
assertEquals("{}", converter.convertToDatabaseColumn(Map.of()));
}
@Test
void fromDb_nullString_yieldsEmptyMap() {
assertTrue(converter.convertToEntityAttribute(null).isEmpty());
}
@Test
void fromDb_blankString_yieldsEmptyMap() {
assertTrue(converter.convertToEntityAttribute(" ").isEmpty());
}
@Test
void fromDb_populatedJson_yieldsMap() {
Map<String, List<String>> result = converter.convertToEntityAttribute(
"{\"Portrait\":[\"42\",\"17\"],\"Carte\":[\"99\"]}");
assertEquals(2, result.size());
assertEquals(List.of("42", "17"), result.get("Portrait"));
assertEquals(List.of("99"), result.get("Carte"));
}
@Test
void fromDb_malformedJson_throwsIllegalStateException() {
assertThrows(IllegalStateException.class,
() -> converter.convertToEntityAttribute("{bad"));
}
@Test
void roundTrip_preservesStructureAndOrder() {
Map<String, List<String>> source = Map.of(
"Portrait", List.of("42", "17"),
"Carte", List.of("99")
);
String json = converter.convertToDatabaseColumn(source);
Map<String, List<String>> back = converter.convertToEntityAttribute(json);
assertEquals(source, back);
assertEquals(List.of("42", "17"), back.get("Portrait"),
"L'ordre des IDs dans la liste est significatif (1ere = principale)");
}
}

View File

@@ -0,0 +1,73 @@
package com.loremind.infrastructure.persistence.converter;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests pour StringMapJsonConverter (Page.values).
* Convention : null/vide -> "{}" en DB, DB null/blank -> map vide en entite.
*/
class StringMapJsonConverterTest {
private final StringMapJsonConverter converter = new StringMapJsonConverter();
@Test
void toDb_nullMap_yieldsEmptyJsonObject() {
assertEquals("{}", converter.convertToDatabaseColumn(null));
}
@Test
void toDb_emptyMap_yieldsEmptyJsonObject() {
assertEquals("{}", converter.convertToDatabaseColumn(Map.of()));
}
@Test
void toDb_populatedMap_yieldsJsonObject() {
// LinkedHashMap pour un ordre deterministe dans l'assertion.
Map<String, String> m = new LinkedHashMap<>();
m.put("a", "1");
m.put("b", "2");
assertEquals("{\"a\":\"1\",\"b\":\"2\"}", converter.convertToDatabaseColumn(m));
}
@Test
void fromDb_nullString_yieldsEmptyMap() {
assertTrue(converter.convertToEntityAttribute(null).isEmpty());
}
@Test
void fromDb_blankString_yieldsEmptyMap() {
assertTrue(converter.convertToEntityAttribute(" ").isEmpty());
}
@Test
void fromDb_emptyJsonObject_yieldsEmptyMap() {
assertTrue(converter.convertToEntityAttribute("{}").isEmpty());
}
@Test
void fromDb_populatedJson_yieldsMap() {
Map<String, String> result = converter.convertToEntityAttribute("{\"histoire\":\"Nee sous une etoile rouge\"}");
assertEquals(1, result.size());
assertEquals("Nee sous une etoile rouge", result.get("histoire"));
}
@Test
void fromDb_malformedJson_throwsIllegalStateException() {
assertThrows(IllegalStateException.class,
() -> converter.convertToEntityAttribute("{not valid"));
}
@Test
void roundTrip_preservesEntries() {
Map<String, String> source = Map.of("histoire", "Nee sous une etoile rouge", "motto", "Jamais");
String json = converter.convertToDatabaseColumn(source);
assertEquals(source, converter.convertToEntityAttribute(json));
}
}

View File

@@ -0,0 +1,211 @@
package com.loremind.infrastructure.persistence.converter;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.ImageLayout;
import com.loremind.domain.lorecontext.TemplateField;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests pour TemplateFieldListJsonConverter.
* Le converter le plus important : il gere la RETROCOMPATIBILITE entre le
* format legacy (liste de strings) et le nouveau format (liste d'objets
* {name, type, layout}). Chaque test documente un cas de migration implicite.
*/
class TemplateFieldListJsonConverterTest {
private final TemplateFieldListJsonConverter converter = new TemplateFieldListJsonConverter();
// ---------- toDb : ecrit toujours le nouveau format --------------------
@Test
void toDb_nullList_yieldsEmptyArray() {
assertEquals("[]", converter.convertToDatabaseColumn(null));
}
@Test
void toDb_emptyList_yieldsEmptyArray() {
assertEquals("[]", converter.convertToDatabaseColumn(List.of()));
}
@Test
void toDb_writesObjectFormat_notLegacyStrings() {
// Test cle : meme avec un TemplateField "simple" (TEXT), on ecrit
// l'objet complet, jamais la chaine. C'est ce qui permet la
// migration implicite a la 1ere sauvegarde.
String json = converter.convertToDatabaseColumn(List.of(TemplateField.text("histoire")));
assertTrue(json.contains("\"name\":\"histoire\""));
assertTrue(json.contains("\"type\":\"TEXT\""));
}
// ---------- fromDb : format legacy (chaines) ---------------------------
@Test
void fromDb_legacyFormat_readsStringsAsTextFields() {
List<TemplateField> result = converter.convertToEntityAttribute(
"[\"Nom\",\"Histoire\",\"Portrait\"]");
assertEquals(3, result.size());
for (TemplateField f : result) {
assertEquals(FieldType.TEXT, f.getType(),
"Format legacy -> tous interpretes comme TEXT");
assertNull(f.getLayout(), "TEXT n'a pas de layout");
}
assertEquals("Nom", result.get(0).getName());
assertEquals("Portrait", result.get(2).getName());
}
// ---------- fromDb : nouveau format ------------------------------------
@Test
void fromDb_newFormat_readsTextField() {
List<TemplateField> result = converter.convertToEntityAttribute(
"[{\"name\":\"histoire\",\"type\":\"TEXT\"}]");
assertEquals(1, result.size());
assertEquals("histoire", result.get(0).getName());
assertEquals(FieldType.TEXT, result.get(0).getType());
assertNull(result.get(0).getLayout());
}
@Test
void fromDb_newFormat_readsImageFieldWithLayout() {
List<TemplateField> result = converter.convertToEntityAttribute(
"[{\"name\":\"portrait\",\"type\":\"IMAGE\",\"layout\":\"HERO\"}]");
assertEquals(1, result.size());
assertEquals(FieldType.IMAGE, result.get(0).getType());
assertEquals(ImageLayout.HERO, result.get(0).getLayout());
}
@Test
void fromDb_newFormat_imageFieldWithoutLayout_keepsNull() {
// layout null cote domaine -> rendu GALLERY par defaut cote UI.
List<TemplateField> result = converter.convertToEntityAttribute(
"[{\"name\":\"gallery\",\"type\":\"IMAGE\"}]");
assertEquals(FieldType.IMAGE, result.get(0).getType());
assertNull(result.get(0).getLayout());
}
@Test
void fromDb_newFormat_imageFieldWithBlankLayout_keepsNull() {
List<TemplateField> result = converter.convertToEntityAttribute(
"[{\"name\":\"gallery\",\"type\":\"IMAGE\",\"layout\":\"\"}]");
assertNull(result.get(0).getLayout());
}
// ---------- fromDb : tolerance aux types/layouts inconnus --------------
@Test
void fromDb_unknownType_fallsBackToText() {
// Tolerance cross-version : si une version future ajoute RICH_TEXT et
// qu'on redescend vers cette version, on ne plante pas, on degrade.
List<TemplateField> result = converter.convertToEntityAttribute(
"[{\"name\":\"nouveau\",\"type\":\"RICH_TEXT\"}]");
assertEquals(1, result.size());
assertEquals(FieldType.TEXT, result.get(0).getType());
}
@Test
void fromDb_unknownLayout_keepsNull() {
List<TemplateField> result = converter.convertToEntityAttribute(
"[{\"name\":\"img\",\"type\":\"IMAGE\",\"layout\":\"SPIRAL\"}]");
assertEquals(FieldType.IMAGE, result.get(0).getType());
assertNull(result.get(0).getLayout(), "Layout inconnu -> null -> GALLERY cote UI");
}
// ---------- fromDb : filtrage d'entrees invalides ---------------------
@Test
void fromDb_objectWithoutName_isSilentlyIgnored() {
List<TemplateField> result = converter.convertToEntityAttribute(
"[{\"type\":\"TEXT\"},{\"name\":\"valide\",\"type\":\"TEXT\"}]");
assertEquals(1, result.size());
assertEquals("valide", result.get(0).getName());
}
@Test
void fromDb_objectWithBlankName_isSilentlyIgnored() {
List<TemplateField> result = converter.convertToEntityAttribute(
"[{\"name\":\" \",\"type\":\"TEXT\"}]");
assertTrue(result.isEmpty());
}
@Test
void fromDb_nonObjectNonStringItem_isSilentlyIgnored() {
// Ex: nombre ou boolean dans le tableau (jamais produit par nos ecritures
// mais on est tolerant).
List<TemplateField> result = converter.convertToEntityAttribute(
"[42, true, \"Nom\"]");
assertEquals(1, result.size());
assertEquals("Nom", result.get(0).getName());
}
// ---------- fromDb : non-arrays et erreurs -----------------------------
@Test
void fromDb_nonArrayRoot_yieldsEmptyList() {
// Si le JSON n'est pas un tableau (corruption ou migration ratee),
// on renvoie une liste vide plutot que de planter.
assertTrue(converter.convertToEntityAttribute("{\"oops\":true}").isEmpty());
}
@Test
void fromDb_nullString_yieldsEmptyList() {
assertTrue(converter.convertToEntityAttribute(null).isEmpty());
}
@Test
void fromDb_blankString_yieldsEmptyList() {
assertTrue(converter.convertToEntityAttribute(" ").isEmpty());
}
@Test
void fromDb_malformedJson_throwsIllegalStateException() {
assertThrows(IllegalStateException.class,
() -> converter.convertToEntityAttribute("[{not json}]"));
}
// ---------- Round-trip + migration -------------------------------------
@Test
void roundTrip_preservesMixedTextAndImageFields() {
List<TemplateField> source = List.of(
TemplateField.text("histoire"),
TemplateField.image("portraits", ImageLayout.MASONRY),
TemplateField.text("motto"),
TemplateField.image("cartes", ImageLayout.CAROUSEL)
);
String json = converter.convertToDatabaseColumn(source);
List<TemplateField> back = converter.convertToEntityAttribute(json);
assertEquals(4, back.size());
assertEquals("histoire", back.get(0).getName());
assertEquals(FieldType.TEXT, back.get(0).getType());
assertEquals("portraits", back.get(1).getName());
assertEquals(FieldType.IMAGE, back.get(1).getType());
assertEquals(ImageLayout.MASONRY, back.get(1).getLayout());
assertEquals(ImageLayout.CAROUSEL, back.get(3).getLayout());
}
@Test
void legacyToNew_migration_isIdempotentAfterFirstWrite() {
// Un template persiste au format legacy est relu comme une liste de
// TemplateField TEXT. La prochaine ecriture produit le nouveau format
// -> la deuxieme relecture donne le meme resultat.
List<TemplateField> pass1 = converter.convertToEntityAttribute("[\"A\",\"B\"]");
String rewritten = converter.convertToDatabaseColumn(pass1);
List<TemplateField> pass2 = converter.convertToEntityAttribute(rewritten);
assertEquals(pass1.size(), pass2.size());
for (int i = 0; i < pass1.size(); i++) {
assertEquals(pass1.get(i).getName(), pass2.get(i).getName());
assertEquals(pass1.get(i).getType(), pass2.get(i).getType());
}
}
}

View File

@@ -0,0 +1,91 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests d'integration pour PostgresArcRepository.
* Valide la persistance des 3 collections JSONB (relatedPageIds,
* illustrationImageIds, mapImageIds) et des 5 champs narratifs enrichis.
*/
@SpringBootTest
@Transactional
class PostgresArcRepositoryTest {
@Autowired private ArcRepository repository;
@Autowired private CampaignRepository campaignRepository;
private String campaignId;
@BeforeEach
void setUp() {
campaignId = campaignRepository.save(
Campaign.builder().name("Camp").description("").build()).getId();
}
@Test
void save_arcWithAllFields_roundTrips() {
Arc arc = Arc.builder()
.campaignId(campaignId).name("Acte I").description("Mise en place").order(0)
.themes("trahison").stakes("survie").gmNotes("secret").rewards("artefact").resolution("couronnement")
.relatedPageIds(List.of("page-1"))
.illustrationImageIds(List.of("img-a", "img-b"))
.mapImageIds(List.of("map-1"))
.build();
Arc saved = repository.save(arc);
assertNotNull(saved.getId());
Arc r = repository.findById(saved.getId()).orElseThrow();
assertEquals("Acte I", r.getName());
assertEquals("trahison", r.getThemes());
assertEquals("secret", r.getGmNotes());
assertEquals(List.of("page-1"), r.getRelatedPageIds());
assertEquals(2, r.getIllustrationImageIds().size());
assertEquals(List.of("map-1"), r.getMapImageIds());
}
@Test
void findByCampaignId_returnsArcsOfThatCampaign() {
String otherCamp = campaignRepository.save(
Campaign.builder().name("Other").description("").build()).getId();
repository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build());
repository.save(Arc.builder().campaignId(campaignId).name("B").order(1).build());
repository.save(Arc.builder().campaignId(otherCamp).name("C").order(0).build());
assertEquals(2, repository.findByCampaignId(campaignId).size());
}
@Test
void deleteById_removesArc() {
Arc saved = repository.save(Arc.builder().campaignId(campaignId).name("X").order(0).build());
repository.deleteById(saved.getId());
assertFalse(repository.existsById(saved.getId()));
}
@Test
void save_emptyCollections_roundTripAsEmpty() {
Arc arc = Arc.builder().campaignId(campaignId).name("Minimal").order(0).build();
Arc saved = repository.save(arc);
Arc r = repository.findById(saved.getId()).orElseThrow();
assertNotNull(r.getRelatedPageIds());
assertTrue(r.getRelatedPageIds().isEmpty());
assertTrue(r.getIllustrationImageIds().isEmpty());
assertTrue(r.getMapImageIds().isEmpty());
}
}

View File

@@ -0,0 +1,89 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests d'integration pour PostgresCampaignRepository.
* Valide le champ optionnel {@code loreId} (weak reference cross-context).
*/
@SpringBootTest
@Transactional
class PostgresCampaignRepositoryTest {
@Autowired private CampaignRepository repository;
@Test
void save_campaignWithLoreLink_roundTrips() {
Campaign c = Campaign.builder()
.name("Les Ombres").description("Dark fantasy")
.loreId("lore-123").build();
Campaign saved = repository.save(c);
assertNotNull(saved.getId());
Campaign r = repository.findById(saved.getId()).orElseThrow();
assertEquals("Les Ombres", r.getName());
assertEquals("lore-123", r.getLoreId());
assertTrue(r.isLinkedToLore());
}
@Test
void save_oneShotCampaign_hasNullLoreId() {
Campaign c = Campaign.builder().name("One-shot").description("").build();
Campaign saved = repository.save(c);
Campaign r = repository.findById(saved.getId()).orElseThrow();
assertNull(r.getLoreId());
assertFalse(r.isLinkedToLore());
}
@Test
void findAll_returnsAllSavedCampaigns() {
repository.save(Campaign.builder().name("A").description("").build());
repository.save(Campaign.builder().name("B").description("").build());
assertTrue(repository.findAll().size() >= 2);
}
@Test
void searchByName_findsByPartialMatch() {
repository.save(Campaign.builder().name("Les Ombres d'Ithoril").description("").build());
repository.save(Campaign.builder().name("La Porte Noire").description("").build());
repository.save(Campaign.builder().name("Ere du Dragon").description("").build());
List<Campaign> hits = repository.searchByName("ombres");
assertTrue(hits.stream().anyMatch(c -> c.getName().contains("Ombres")));
}
@Test
void deleteById_removesCampaign() {
Campaign saved = repository.save(Campaign.builder().name("X").description("").build());
assertTrue(repository.existsById(saved.getId()));
repository.deleteById(saved.getId());
assertFalse(repository.existsById(saved.getId()));
}
@Test
void save_updatesName_whenSavingExistingCampaign() {
Campaign saved = repository.save(Campaign.builder().name("old").description("").build());
saved.setName("new");
repository.save(saved);
assertEquals("new", repository.findById(saved.getId()).orElseThrow().getName());
}
}

View File

@@ -0,0 +1,79 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest
@Transactional
class PostgresChapterRepositoryTest {
@Autowired private ChapterRepository repository;
@Autowired private ArcRepository arcRepository;
@Autowired private CampaignRepository campaignRepository;
private String arcId;
@BeforeEach
void setUp() {
String campaignId = campaignRepository.save(
Campaign.builder().name("Camp").description("").build()).getId();
arcId = arcRepository.save(Arc.builder().campaignId(campaignId).name("Arc").order(0).build()).getId();
}
@Test
void save_chapterWithAllFields_roundTrips() {
Chapter chapter = Chapter.builder()
.arcId(arcId).name("L'arrivee").description("Les PJ decouvrent la ville").order(0)
.gmNotes("note secrete").playerObjectives("trouver l'indice").narrativeStakes("si echec allie meurt")
.relatedPageIds(List.of("page-x"))
.illustrationImageIds(List.of("img-1"))
.mapImageIds(List.of("map-donjon"))
.build();
Chapter saved = repository.save(chapter);
assertNotNull(saved.getId());
Chapter r = repository.findById(saved.getId()).orElseThrow();
assertEquals("L'arrivee", r.getName());
assertEquals("note secrete", r.getGmNotes());
assertEquals("trouver l'indice", r.getPlayerObjectives());
assertEquals(List.of("page-x"), r.getRelatedPageIds());
assertEquals(List.of("map-donjon"), r.getMapImageIds());
}
@Test
void findByArcId_returnsChaptersOfThatArc() {
String campaignId = campaignRepository.save(
Campaign.builder().name("Camp2").description("").build()).getId();
String otherArc = arcRepository.save(Arc.builder().campaignId(campaignId).name("A2").order(0).build()).getId();
repository.save(Chapter.builder().arcId(arcId).name("Ch1").order(0).build());
repository.save(Chapter.builder().arcId(arcId).name("Ch2").order(1).build());
repository.save(Chapter.builder().arcId(otherArc).name("Ch3").order(0).build());
assertEquals(2, repository.findByArcId(arcId).size());
}
@Test
void deleteById_removesChapter() {
Chapter saved = repository.save(Chapter.builder().arcId(arcId).name("X").order(0).build());
assertTrue(repository.existsById(saved.getId()));
repository.deleteById(saved.getId());
assertFalse(repository.existsById(saved.getId()));
}
}

View File

@@ -0,0 +1,140 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.conversationcontext.Conversation;
import com.loremind.domain.conversationcontext.ConversationMessage;
import com.loremind.domain.conversationcontext.ports.ConversationRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests d'integration pour PostgresConversationRepository.
* Focus particulier sur le filtrage contextuel (findByContext) avec ses 4
* variantes : Lore racine, Lore + entite, Campaign racine, Campaign + entite.
*/
@SpringBootTest
@Transactional
class PostgresConversationRepositoryTest {
@Autowired private ConversationRepository repository;
@Test
void save_conversationWithLoreAnchor_roundTrips() {
Conversation c = Conversation.builder()
.title("Discussion Thorin")
.loreId("lore-1")
.entityType("page").entityId("page-42")
.build();
Conversation saved = repository.save(c);
assertNotNull(saved.getId());
Conversation r = repository.findById(saved.getId()).orElseThrow();
assertEquals("Discussion Thorin", r.getTitle());
assertEquals("lore-1", r.getLoreId());
assertEquals("page", r.getEntityType());
assertEquals("page-42", r.getEntityId());
}
@Test
void save_conversationWithCampaignAnchor_roundTrips() {
Conversation c = Conversation.builder()
.title("Sur la scene")
.campaignId("camp-1")
.entityType("scene").entityId("sc-7")
.build();
Conversation saved = repository.save(c);
Conversation r = repository.findById(saved.getId()).orElseThrow();
assertEquals("camp-1", r.getCampaignId());
assertEquals("scene", r.getEntityType());
}
@Test
void findByContext_lorerootExcludesLoreWithEntityFocus() {
// Lore racine : loreId=X, entityType=null, entityId=null
repository.save(Conversation.builder().title("root-1").loreId("lore-1").build());
repository.save(Conversation.builder().title("root-2").loreId("lore-1").build());
// Meme lore MAIS focus sur une page — ne doit PAS apparaitre dans la liste racine.
repository.save(Conversation.builder().title("page-scoped")
.loreId("lore-1").entityType("page").entityId("p-1").build());
List<Conversation> roots = repository.findByContext("lore-1", null, null, null);
assertEquals(2, roots.size());
assertTrue(roots.stream().allMatch(c -> c.getEntityType() == null));
}
@Test
void findByContext_loreWithEntityFocus_filtersByType_andId() {
repository.save(Conversation.builder().title("p1")
.loreId("lore-1").entityType("page").entityId("p-1").build());
repository.save(Conversation.builder().title("p1-bis")
.loreId("lore-1").entityType("page").entityId("p-1").build());
repository.save(Conversation.builder().title("p2")
.loreId("lore-1").entityType("page").entityId("p-2").build());
List<Conversation> hits = repository.findByContext("lore-1", null, "page", "p-1");
assertEquals(2, hits.size());
}
@Test
void findByContext_campaignRoot_excludesCampaignWithEntityFocus() {
repository.save(Conversation.builder().title("c-root").campaignId("camp-1").build());
repository.save(Conversation.builder().title("c-scene")
.campaignId("camp-1").entityType("scene").entityId("sc-1").build());
List<Conversation> roots = repository.findByContext(null, "camp-1", null, null);
assertEquals(1, roots.size());
assertEquals("c-root", roots.get(0).getTitle());
}
@Test
void appendMessage_persistsNewMessage_andFindByIdExposesIt() {
Conversation saved = repository.save(Conversation.builder()
.title("t").loreId("lore-1").build());
repository.appendMessage(saved.getId(),
ConversationMessage.builder().role("user").content("bonjour").build());
repository.appendMessage(saved.getId(),
ConversationMessage.builder().role("assistant").content("salut").build());
Conversation reloaded = repository.findById(saved.getId()).orElseThrow();
assertEquals(2, reloaded.getMessages().size());
assertEquals("user", reloaded.getMessages().get(0).getRole());
assertEquals("assistant", reloaded.getMessages().get(1).getRole());
assertEquals("bonjour", reloaded.getMessages().get(0).getContent());
}
@Test
void updateTitle_changesTitle_withoutTouchingMessages() {
Conversation saved = repository.save(Conversation.builder()
.title("ancien").loreId("lore-1").build());
repository.appendMessage(saved.getId(),
ConversationMessage.builder().role("user").content("test").build());
repository.updateTitle(saved.getId(), "nouveau");
Conversation r = repository.findById(saved.getId()).orElseThrow();
assertEquals("nouveau", r.getTitle());
assertEquals(1, r.getMessages().size(), "Les messages ne doivent pas etre affectes");
}
@Test
void deleteById_removesConversation() {
Conversation saved = repository.save(Conversation.builder()
.title("t").loreId("lore-1").build());
assertTrue(repository.findById(saved.getId()).isPresent());
repository.deleteById(saved.getId());
assertFalse(repository.findById(saved.getId()).isPresent());
}
}

View File

@@ -0,0 +1,65 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.images.Image;
import com.loremind.domain.images.ports.ImageRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests d'integration pour PostgresImageRepository.
* Image est un Shared Kernel : juste metadata + cle opaque vers MinIO.
*/
@SpringBootTest
@Transactional
class PostgresImageRepositoryTest {
@Autowired private ImageRepository repository;
@Test
void save_imageWithAllMetadata_roundTrips() {
Image image = Image.builder()
.filename("portrait-elfe.jpg")
.contentType("image/jpeg")
.sizeBytes(125_000L)
.storageKey("images/abc123.jpg")
.uploadedAt(LocalDateTime.now())
.build();
Image saved = repository.save(image);
assertNotNull(saved.getId());
Image r = repository.findById(saved.getId()).orElseThrow();
assertEquals("portrait-elfe.jpg", r.getFilename());
assertEquals("image/jpeg", r.getContentType());
assertEquals(125_000L, r.getSizeBytes());
assertEquals("images/abc123.jpg", r.getStorageKey());
assertNotNull(r.getUploadedAt());
}
@Test
void deleteById_removesImage() {
Image saved = repository.save(Image.builder()
.filename("x.png").contentType("image/png").sizeBytes(100L)
.storageKey("k").uploadedAt(LocalDateTime.now()).build());
assertTrue(repository.existsById(saved.getId()));
repository.deleteById(saved.getId());
assertFalse(repository.existsById(saved.getId()));
}
@Test
void existsById_returnsFalse_forUnknownId() {
// L'id cote DB est un BIGSERIAL parse via Long.parseLong cote adapter.
// On passe donc un nombre "impossible" plutot qu'une chaine non numerique.
assertFalse(repository.existsById("999999999"));
}
}

View File

@@ -0,0 +1,115 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests d'integration pour PostgresLoreNodeRepository.
* Pattern : @SpringBootTest + @Transactional -> rollback automatique apres
* chaque test, pas de pollution inter-tests.
*/
@SpringBootTest
@Transactional
class PostgresLoreNodeRepositoryTest {
@Autowired private LoreNodeRepository nodeRepository;
@Autowired private LoreRepository loreRepository;
private String loreId;
@BeforeEach
void setUp() {
Lore lore = loreRepository.save(Lore.builder().name("Lore host").description("").build());
this.loreId = lore.getId();
}
@Test
void save_assignsId_andFindByIdReturnsNode() {
LoreNode node = LoreNode.builder()
.name("Personnages").icon("users").loreId(loreId).build();
LoreNode saved = nodeRepository.save(node);
assertNotNull(saved.getId());
LoreNode found = nodeRepository.findById(saved.getId()).orElseThrow();
assertEquals("Personnages", found.getName());
assertEquals("users", found.getIcon());
assertEquals(loreId, found.getLoreId());
}
@Test
void findByLoreId_returnsOnlyNodesOfThatLore() {
Lore other = loreRepository.save(Lore.builder().name("Other").description("").build());
nodeRepository.save(LoreNode.builder().name("A").loreId(loreId).build());
nodeRepository.save(LoreNode.builder().name("B").loreId(loreId).build());
nodeRepository.save(LoreNode.builder().name("C").loreId(other.getId()).build());
List<LoreNode> mine = nodeRepository.findByLoreId(loreId);
assertEquals(2, mine.size());
}
@Test
void findByParentId_returnsChildrenOfGivenParent() {
LoreNode parent = nodeRepository.save(LoreNode.builder().name("Parent").loreId(loreId).build());
nodeRepository.save(LoreNode.builder().name("Child1").parentId(parent.getId()).loreId(loreId).build());
nodeRepository.save(LoreNode.builder().name("Child2").parentId(parent.getId()).loreId(loreId).build());
nodeRepository.save(LoreNode.builder().name("Orphan").loreId(loreId).build());
List<LoreNode> children = nodeRepository.findByParentId(parent.getId());
assertEquals(2, children.size());
}
@Test
void countByLoreId_countsNodesAccurately() {
nodeRepository.save(LoreNode.builder().name("A").loreId(loreId).build());
nodeRepository.save(LoreNode.builder().name("B").loreId(loreId).build());
nodeRepository.save(LoreNode.builder().name("C").loreId(loreId).build());
assertEquals(3, nodeRepository.countByLoreId(loreId));
}
@Test
void searchByName_isCaseInsensitiveAndPartial() {
nodeRepository.save(LoreNode.builder().name("Personnages").loreId(loreId).build());
nodeRepository.save(LoreNode.builder().name("Lieux").loreId(loreId).build());
nodeRepository.save(LoreNode.builder().name("Creatures").loreId(loreId).build());
// Recherche partielle attendue — on valide qu'on trouve bien au moins le hit.
List<LoreNode> hits = nodeRepository.searchByName("person");
assertTrue(hits.stream().anyMatch(n -> n.getName().equals("Personnages")));
}
@Test
void deleteById_removesNode_andExistsReturnsFalse() {
LoreNode saved = nodeRepository.save(LoreNode.builder().name("X").loreId(loreId).build());
assertTrue(nodeRepository.existsById(saved.getId()));
nodeRepository.deleteById(saved.getId());
assertFalse(nodeRepository.existsById(saved.getId()));
assertTrue(nodeRepository.findById(saved.getId()).isEmpty());
}
@Test
void save_updatesExistingNode_whenIdIsPresent() {
LoreNode saved = nodeRepository.save(LoreNode.builder().name("old").loreId(loreId).build());
saved.setName("new");
nodeRepository.save(saved);
LoreNode reloaded = nodeRepository.findById(saved.getId()).orElseThrow();
assertEquals("new", reloaded.getName());
}
}

View File

@@ -0,0 +1,157 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests d'integration pour PostgresPageRepository.
* Valide la persistance des 4 collections JSONB : values, imageValues, tags,
* relatedPageIds — via les AttributeConverter.
*/
@SpringBootTest
@Transactional
class PostgresPageRepositoryTest {
@Autowired private PageRepository pageRepository;
@Autowired private LoreRepository loreRepository;
@Autowired private LoreNodeRepository nodeRepository;
@Autowired private TemplateRepository templateRepository;
private String loreId;
private String nodeId;
private String templateId;
@BeforeEach
void setUp() {
loreId = loreRepository.save(Lore.builder().name("Lore").description("").build()).getId();
nodeId = nodeRepository.save(LoreNode.builder().name("Node").loreId(loreId).build()).getId();
templateId = templateRepository.save(Template.builder()
.loreId(loreId).name("Tpl").fields(List.of()).build()).getId();
}
@Test
void save_persistsPageWithAllJsonbFields_andRoundTrips() {
Page page = Page.builder()
.loreId(loreId)
.nodeId(nodeId)
.templateId(templateId)
.title("Thorin")
.values(Map.of("histoire", "Nee sous une etoile rouge", "motto", "Jamais"))
.imageValues(Map.of("portraits", List.of("img-1", "img-2")))
.notes("Secret MJ")
.tags(List.of("pnj", "allie"))
.relatedPageIds(List.of("page-x"))
.build();
Page saved = pageRepository.save(page);
assertNotNull(saved.getId());
Page r = pageRepository.findById(saved.getId()).orElseThrow();
assertEquals("Thorin", r.getTitle());
assertEquals("Nee sous une etoile rouge", r.getValues().get("histoire"));
assertEquals(List.of("img-1", "img-2"), r.getImageValues().get("portraits"));
assertEquals("Secret MJ", r.getNotes());
assertEquals(2, r.getTags().size());
assertEquals(List.of("page-x"), r.getRelatedPageIds());
}
@Test
void findByLoreId_returnsOnlyPagesOfThatLore() {
Lore other = loreRepository.save(Lore.builder().name("Other").description("").build());
String otherNode = nodeRepository.save(LoreNode.builder().name("N").loreId(other.getId()).build()).getId();
String otherTpl = templateRepository.save(Template.builder().loreId(other.getId()).name("T").fields(List.of()).build()).getId();
pageRepository.save(buildMinimal(loreId, nodeId, templateId, "A"));
pageRepository.save(buildMinimal(loreId, nodeId, templateId, "B"));
pageRepository.save(buildMinimal(other.getId(), otherNode, otherTpl, "C"));
assertEquals(2, pageRepository.findByLoreId(loreId).size());
}
@Test
void findByNodeId_returnsPagesInThatFolder() {
String otherNode = nodeRepository.save(LoreNode.builder().name("Other").loreId(loreId).build()).getId();
pageRepository.save(buildMinimal(loreId, nodeId, templateId, "A"));
pageRepository.save(buildMinimal(loreId, nodeId, templateId, "B"));
pageRepository.save(buildMinimal(loreId, otherNode, templateId, "C"));
assertEquals(2, pageRepository.findByNodeId(nodeId).size());
}
@Test
void countByLoreId_matchesSaveCount() {
pageRepository.save(buildMinimal(loreId, nodeId, templateId, "A"));
pageRepository.save(buildMinimal(loreId, nodeId, templateId, "B"));
pageRepository.save(buildMinimal(loreId, nodeId, templateId, "C"));
assertEquals(3, pageRepository.countByLoreId(loreId));
}
@Test
void searchByTitle_findsHits() {
pageRepository.save(buildMinimal(loreId, nodeId, templateId, "Thorin"));
pageRepository.save(buildMinimal(loreId, nodeId, templateId, "Thalia"));
pageRepository.save(buildMinimal(loreId, nodeId, templateId, "Garde"));
List<Page> hits = pageRepository.searchByTitle("tho");
assertTrue(hits.size() >= 1);
assertTrue(hits.stream().anyMatch(p -> p.getTitle().equals("Thorin")));
}
@Test
void deleteById_removesPage() {
Page saved = pageRepository.save(buildMinimal(loreId, nodeId, templateId, "X"));
assertTrue(pageRepository.existsById(saved.getId()));
pageRepository.deleteById(saved.getId());
assertFalse(pageRepository.existsById(saved.getId()));
}
@Test
void save_nullCollections_areStoredAsEmpty_afterReload() {
// Les converters convertissent null -> "{}" / "[]" donc le reload
// rend une collection vide plutot que null.
Page page = Page.builder()
.loreId(loreId).nodeId(nodeId).templateId(templateId).title("Minimal")
.values(null).imageValues(null).tags(null).relatedPageIds(null)
.build();
Page saved = pageRepository.save(page);
Page r = pageRepository.findById(saved.getId()).orElseThrow();
assertNotNull(r.getValues());
assertTrue(r.getValues().isEmpty());
assertNotNull(r.getImageValues());
assertNotNull(r.getTags());
assertNotNull(r.getRelatedPageIds());
}
// --- helper ------------------------------------------------------------
private static Page buildMinimal(String loreId, String nodeId, String tplId, String title) {
return Page.builder()
.loreId(loreId).nodeId(nodeId).templateId(tplId).title(title)
.values(Map.of()).imageValues(Map.of())
.tags(List.of()).relatedPageIds(List.of())
.build();
}
}

View File

@@ -0,0 +1,113 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests d'integration pour PostgresSceneRepository.
* Valide particulierement la persistance de la liste SceneBranch (JSONB avec
* Value Object immuable + @Jacksonized).
*/
@SpringBootTest
@Transactional
class PostgresSceneRepositoryTest {
@Autowired private SceneRepository repository;
@Autowired private ChapterRepository chapterRepository;
@Autowired private ArcRepository arcRepository;
@Autowired private CampaignRepository campaignRepository;
private String chapterId;
@BeforeEach
void setUp() {
String campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId();
String arcId = arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build()).getId();
chapterId = chapterRepository.save(Chapter.builder().arcId(arcId).name("Ch").order(0).build()).getId();
}
@Test
void save_sceneWithAllFields_roundTrips() {
Scene scene = Scene.builder()
.chapterId(chapterId).name("L'auberge").description("Rencontre tendue").order(0)
.location("Taverne du Dragon d'Or").timing("Soir").atmosphere("fumee, rires")
.playerNarration("Vous entrez...").gmSecretNotes("Piege cache")
.choicesConsequences("Si attaque -> gardes").combatDifficulty("facile").enemies("3 brigands")
.relatedPageIds(List.of("page-aubergiste"))
.illustrationImageIds(List.of("img-1", "img-2"))
.mapImageIds(List.of("plan-taverne"))
.build();
Scene saved = repository.save(scene);
assertNotNull(saved.getId());
Scene r = repository.findById(saved.getId()).orElseThrow();
assertEquals("L'auberge", r.getName());
assertEquals("Taverne du Dragon d'Or", r.getLocation());
assertEquals("Piege cache", r.getGmSecretNotes());
assertEquals(2, r.getIllustrationImageIds().size());
assertEquals(List.of("plan-taverne"), r.getMapImageIds());
}
@Test
void save_scenePreservesBranches_viaJsonbRoundTrip() {
// Le critique : le @Jacksonized de SceneBranch doit permettre la
// reconstruction via builder apres serialisation Jackson.
Scene scene = Scene.builder()
.chapterId(chapterId).name("Decision").order(0)
.branches(List.of(
SceneBranch.builder().label("fuite").targetSceneId("sc-2").condition("HP bas").build(),
SceneBranch.builder().label("combat").targetSceneId("sc-3").build()
))
.build();
Scene saved = repository.save(scene);
Scene r = repository.findById(saved.getId()).orElseThrow();
assertEquals(2, r.getBranches().size());
assertEquals("fuite", r.getBranches().get(0).getLabel());
assertEquals("sc-2", r.getBranches().get(0).getTargetSceneId());
assertEquals("HP bas", r.getBranches().get(0).getCondition());
assertEquals("combat", r.getBranches().get(1).getLabel());
}
@Test
void findByChapterId_returnsScenesOfThatChapter() {
String campaignId = campaignRepository.save(Campaign.builder().name("C2").description("").build()).getId();
String arcId = arcRepository.save(Arc.builder().campaignId(campaignId).name("A2").order(0).build()).getId();
String otherChapter = chapterRepository.save(Chapter.builder().arcId(arcId).name("Ch2").order(0).build()).getId();
repository.save(Scene.builder().chapterId(chapterId).name("A").order(0).build());
repository.save(Scene.builder().chapterId(chapterId).name("B").order(1).build());
repository.save(Scene.builder().chapterId(otherChapter).name("C").order(0).build());
assertEquals(2, repository.findByChapterId(chapterId).size());
}
@Test
void deleteById_removesScene() {
Scene saved = repository.save(Scene.builder().chapterId(chapterId).name("X").order(0).build());
assertTrue(repository.existsById(saved.getId()));
repository.deleteById(saved.getId());
assertFalse(repository.existsById(saved.getId()));
}
}

View File

@@ -0,0 +1,109 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.ImageLayout;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests d'integration pour PostgresTemplateRepository.
* Focus particulier sur la persistance de la liste {@link TemplateField}
* via le JSONB converter : roundtrip texte+image+layout.
*/
@SpringBootTest
@Transactional
class PostgresTemplateRepositoryTest {
@Autowired private TemplateRepository repository;
@Autowired private LoreRepository loreRepository;
private String loreId;
@BeforeEach
void setUp() {
loreId = loreRepository.save(Lore.builder().name("Lore host").description("").build()).getId();
}
@Test
void save_persistsTemplateWithMixedFields_andRoundTrips() {
Template tpl = Template.builder()
.loreId(loreId)
.name("Fiche PNJ")
.description("Template de base pour les PNJ")
.fields(List.of(
TemplateField.text("histoire"),
TemplateField.image("portraits", ImageLayout.MASONRY),
TemplateField.text("motto")
))
.build();
Template saved = repository.save(tpl);
assertNotNull(saved.getId());
Template reloaded = repository.findById(saved.getId()).orElseThrow();
assertEquals("Fiche PNJ", reloaded.getName());
assertEquals(3, reloaded.getFields().size());
assertEquals(FieldType.TEXT, reloaded.getFields().get(0).getType());
assertEquals(FieldType.IMAGE, reloaded.getFields().get(1).getType());
assertEquals(ImageLayout.MASONRY, reloaded.getFields().get(1).getLayout());
}
@Test
void findByLoreId_returnsOnlyTemplatesOfThatLore() {
Lore other = loreRepository.save(Lore.builder().name("Other").description("").build());
repository.save(Template.builder().loreId(loreId).name("A").fields(List.of()).build());
repository.save(Template.builder().loreId(loreId).name("B").fields(List.of()).build());
repository.save(Template.builder().loreId(other.getId()).name("C").fields(List.of()).build());
assertEquals(2, repository.findByLoreId(loreId).size());
}
@Test
void searchByName_findsMatches() {
repository.save(Template.builder().loreId(loreId).name("Fiche PNJ").fields(List.of()).build());
repository.save(Template.builder().loreId(loreId).name("Fiche Lieu").fields(List.of()).build());
repository.save(Template.builder().loreId(loreId).name("Creature").fields(List.of()).build());
List<Template> hits = repository.searchByName("fiche");
assertTrue(hits.size() >= 2);
assertTrue(hits.stream().allMatch(t -> t.getName().toLowerCase().contains("fiche")));
}
@Test
void deleteById_removesTemplate() {
Template saved = repository.save(Template.builder().loreId(loreId).name("X").fields(List.of()).build());
assertTrue(repository.existsById(saved.getId()));
repository.deleteById(saved.getId());
assertFalse(repository.existsById(saved.getId()));
}
@Test
void save_updatesExistingTemplate_andPreservesId() {
Template saved = repository.save(Template.builder().loreId(loreId).name("old").fields(List.of()).build());
String id = saved.getId();
saved.setName("new");
saved.setFields(List.of(TemplateField.text("champ")));
repository.save(saved);
Template reloaded = repository.findById(id).orElseThrow();
assertEquals("new", reloaded.getName());
assertEquals(1, reloaded.getFields().size());
}
}

View File

@@ -0,0 +1,107 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.infrastructure.web.dto.campaigncontext.ArcDTO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class ArcControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private CampaignRepository campaignRepository;
@Autowired private ArcRepository arcRepository;
private String campaignId;
@BeforeEach
void setUp() {
campaignId = campaignRepository.save(Campaign.builder().name("C").description("").build()).getId();
}
@Test
void create_returns200() throws Exception {
ArcDTO dto = new ArcDTO();
dto.setName("Acte I");
dto.setDescription("Mise en place");
dto.setCampaignId(campaignId);
dto.setOrder(0);
mockMvc.perform(post("/api/arcs")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Acte I"));
}
@Test
void getById_returns200() throws Exception {
Arc saved = arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build());
mockMvc.perform(get("/api/arcs/{id}", saved.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("A"));
}
@Test
void getById_returns404_whenMissing() throws Exception {
mockMvc.perform(get("/api/arcs/{id}", "999999999"))
.andExpect(status().isNotFound());
}
@Test
void getAll_returnsArray() throws Exception {
arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build());
mockMvc.perform(get("/api/arcs"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
void getByCampaign_pathVariant() throws Exception {
arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build());
mockMvc.perform(get("/api/arcs/campaign/{id}", campaignId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
void update_returns200() throws Exception {
Arc saved = arcRepository.save(Arc.builder().campaignId(campaignId).name("old").order(0).build());
ArcDTO dto = new ArcDTO();
dto.setName("new");
dto.setCampaignId(campaignId);
dto.setOrder(0);
mockMvc.perform(put("/api/arcs/{id}", saved.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("new"));
}
@Test
void delete_returns204() throws Exception {
Arc saved = arcRepository.save(Arc.builder().campaignId(campaignId).name("X").order(0).build());
mockMvc.perform(delete("/api/arcs/{id}", saved.getId()))
.andExpect(status().isNoContent());
}
}

View File

@@ -0,0 +1,95 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.infrastructure.web.dto.campaigncontext.CampaignDTO;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class CampaignControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private CampaignRepository campaignRepository;
@Test
void create_returns200_withOptionalLoreId() throws Exception {
CampaignDTO dto = new CampaignDTO();
dto.setName("Les Ombres");
dto.setDescription("Dark fantasy");
// loreId laisse null : campagne one-shot
mockMvc.perform(post("/api/campaigns")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.name").value("Les Ombres"));
}
@Test
void getById_returns200() throws Exception {
Campaign saved = campaignRepository.save(Campaign.builder().name("X").description("").build());
mockMvc.perform(get("/api/campaigns/{id}", saved.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("X"));
}
@Test
void getById_returns404_whenMissing() throws Exception {
mockMvc.perform(get("/api/campaigns/{id}", "999999999"))
.andExpect(status().isNotFound());
}
@Test
void getAll_returnsArray() throws Exception {
campaignRepository.save(Campaign.builder().name("A").description("").build());
mockMvc.perform(get("/api/campaigns"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
void search_returnsMatches() throws Exception {
campaignRepository.save(Campaign.builder().name("Les Ombres").description("").build());
mockMvc.perform(get("/api/campaigns/search").param("q", "ombres"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
void update_returns200() throws Exception {
Campaign saved = campaignRepository.save(Campaign.builder().name("old").description("").build());
CampaignDTO dto = new CampaignDTO();
dto.setName("new");
dto.setDescription("d");
mockMvc.perform(put("/api/campaigns/{id}", saved.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("new"));
}
@Test
void delete_returns204() throws Exception {
Campaign saved = campaignRepository.save(Campaign.builder().name("X").description("").build());
mockMvc.perform(delete("/api/campaigns/{id}", saved.getId()))
.andExpect(status().isNoContent());
}
}

Some files were not shown because too many files have changed in this diff Show More