diff --git a/core/lombok.config b/core/lombok.config new file mode 100644 index 0000000..07b0941 --- /dev/null +++ b/core/lombok.config @@ -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 diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java index 5ea4174..628fa7a 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java @@ -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). *

- * 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. + *

+ * Les responsabilités auxiliaires sont déléguées : + * - Construction du payload JSON : {@link BrainChatPayloadBuilder}. + * - Parsing des payloads SSE : {@link BrainSseParser}. *

* 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 onError) { - Map payload = toPayload(request); + Map payload = payloadBuilder.build(request); Flux> 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 sse, Consumer onUsage, Consumer onToken, Consumer 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 toPayload(ChatRequest request) { - Map 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 messageToMap(ChatMessage m) { - Map map = new LinkedHashMap<>(); - map.put("role", m.role()); - map.put("content", m.content()); - return map; - } - - private Map loreContextToMap(LoreStructuralContext ctx) { - Map map = new LinkedHashMap<>(); - map.put("lore_name", ctx.getLoreName()); - map.put("lore_description", ctx.getLoreDescription()); - - Map foldersMap = new LinkedHashMap<>(); - for (Map.Entry> 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 pageSummaryToMap(PageSummary ps) { - Map 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 pageContextToMap(PageContext pc) { - Map 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 campaignContextToMap(CampaignStructuralContext ctx) { - Map 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 Map structuralSummaryToMap( - T entity, - java.util.function.Function nameExtractor, - java.util.function.Function descriptionExtractor, - java.util.function.Function illustrationCountExtractor, - java.util.function.BiConsumer, T> childSerializer) { - Map 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 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 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 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 branchHintToMap(BranchHint b) { - Map 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 narrativeEntityToMap(NarrativeEntityContext ne) { - Map map = new LinkedHashMap<>(); - map.put("entity_type", ne.getEntityType()); - map.put("title", ne.getTitle()); - map.put("fields", ne.getFields()); - return map; - } } diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java new file mode 100644 index 0000000..0e83160 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java @@ -0,0 +1,192 @@ +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.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). + *

+ * 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. + *

+ * 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 build(ChatRequest request) { + Map 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 messageToMap(ChatMessage m) { + Map map = new LinkedHashMap<>(); + map.put("role", m.role()); + map.put("content", m.content()); + return map; + } + + private Map loreContextToMap(LoreStructuralContext ctx) { + Map map = new LinkedHashMap<>(); + map.put("lore_name", ctx.getLoreName()); + map.put("lore_description", ctx.getLoreDescription()); + + Map foldersMap = new LinkedHashMap<>(); + for (Map.Entry> 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 pageSummaryToMap(PageSummary ps) { + Map 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 pageContextToMap(PageContext pc) { + Map 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 campaignContextToMap(CampaignStructuralContext ctx) { + Map 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 générique pour sérialiser les entités structurelles (Arc/Chapter/Scene) + * avec name, description et illustration_count conditionnel. + */ + private Map structuralSummaryToMap( + T entity, + Function nameExtractor, + Function descriptionExtractor, + Function illustrationCountExtractor, + BiConsumer, T> childSerializer) { + Map 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 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 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 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 branchHintToMap(BranchHint b) { + Map 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 narrativeEntityToMap(NarrativeEntityContext ne) { + Map map = new LinkedHashMap<>(); + map.put("entity_type", ne.getEntityType()); + map.put("title", ne.getTitle()); + map.put("fields", ne.getFields()); + return map; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainSseParser.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainSseParser.java new file mode 100644 index 0000000..e023a4d --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainSseParser.java @@ -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. + *

+ * 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)); + } +} diff --git a/core/src/test/java/com/loremind/application/conversationcontext/ConversationServiceTest.java b/core/src/test/java/com/loremind/application/conversationcontext/ConversationServiceTest.java new file mode 100644 index 0000000..bce8dcb --- /dev/null +++ b/core/src/test/java/com/loremind/application/conversationcontext/ConversationServiceTest.java @@ -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 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 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 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 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 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 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 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 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"); + } +} diff --git a/core/src/test/java/com/loremind/domain/campaigncontext/ArcTest.java b/core/src/test/java/com/loremind/domain/campaigncontext/ArcTest.java new file mode 100644 index 0000000..867c481 --- /dev/null +++ b/core/src/test/java/com/loremind/domain/campaigncontext/ArcTest.java @@ -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()); + } +} diff --git a/core/src/test/java/com/loremind/domain/campaigncontext/CampaignTest.java b/core/src/test/java/com/loremind/domain/campaigncontext/CampaignTest.java new file mode 100644 index 0000000..ba0185b --- /dev/null +++ b/core/src/test/java/com/loremind/domain/campaigncontext/CampaignTest.java @@ -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()); + } +} diff --git a/core/src/test/java/com/loremind/domain/campaigncontext/ChapterTest.java b/core/src/test/java/com/loremind/domain/campaigncontext/ChapterTest.java new file mode 100644 index 0000000..cf02c8b --- /dev/null +++ b/core/src/test/java/com/loremind/domain/campaigncontext/ChapterTest.java @@ -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()); + } +} diff --git a/core/src/test/java/com/loremind/domain/campaigncontext/SceneBranchTest.java b/core/src/test/java/com/loremind/domain/campaigncontext/SceneBranchTest.java new file mode 100644 index 0000000..6ce488c --- /dev/null +++ b/core/src/test/java/com/loremind/domain/campaigncontext/SceneBranchTest.java @@ -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); + } +} diff --git a/core/src/test/java/com/loremind/domain/campaigncontext/SceneTest.java b/core/src/test/java/com/loremind/domain/campaigncontext/SceneTest.java new file mode 100644 index 0000000..2c063cf --- /dev/null +++ b/core/src/test/java/com/loremind/domain/campaigncontext/SceneTest.java @@ -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()); + } +} diff --git a/core/src/test/java/com/loremind/domain/conversationcontext/ConversationMessageTest.java b/core/src/test/java/com/loremind/domain/conversationcontext/ConversationMessageTest.java new file mode 100644 index 0000000..2b8e5c0 --- /dev/null +++ b/core/src/test/java/com/loremind/domain/conversationcontext/ConversationMessageTest.java @@ -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()); + } +} diff --git a/core/src/test/java/com/loremind/domain/conversationcontext/ConversationTest.java b/core/src/test/java/com/loremind/domain/conversationcontext/ConversationTest.java new file mode 100644 index 0000000..be7cc93 --- /dev/null +++ b/core/src/test/java/com/loremind/domain/conversationcontext/ConversationTest.java @@ -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). + *

+ * 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()); + } +} diff --git a/core/src/test/java/com/loremind/domain/generationcontext/CampaignStructuralContextTest.java b/core/src/test/java/com/loremind/domain/generationcontext/CampaignStructuralContextTest.java new file mode 100644 index 0000000..0f10d85 --- /dev/null +++ b/core/src/test/java/com/loremind/domain/generationcontext/CampaignStructuralContextTest.java @@ -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()))); + } +} diff --git a/core/src/test/java/com/loremind/domain/generationcontext/ChatMessageTest.java b/core/src/test/java/com/loremind/domain/generationcontext/ChatMessageTest.java new file mode 100644 index 0000000..a0c9f20 --- /dev/null +++ b/core/src/test/java/com/loremind/domain/generationcontext/ChatMessageTest.java @@ -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); + } +} diff --git a/core/src/test/java/com/loremind/domain/generationcontext/ChatRequestTest.java b/core/src/test/java/com/loremind/domain/generationcontext/ChatRequestTest.java new file mode 100644 index 0000000..e0c397a --- /dev/null +++ b/core/src/test/java/com/loremind/domain/generationcontext/ChatRequestTest.java @@ -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 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()); + } +} diff --git a/core/src/test/java/com/loremind/domain/generationcontext/ChatUsageTest.java b/core/src/test/java/com/loremind/domain/generationcontext/ChatUsageTest.java new file mode 100644 index 0000000..3c79d29 --- /dev/null +++ b/core/src/test/java/com/loremind/domain/generationcontext/ChatUsageTest.java @@ -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)); + } +} diff --git a/core/src/test/java/com/loremind/domain/generationcontext/GenerationContextTest.java b/core/src/test/java/com/loremind/domain/generationcontext/GenerationContextTest.java new file mode 100644 index 0000000..86b8873 --- /dev/null +++ b/core/src/test/java/com/loremind/domain/generationcontext/GenerationContextTest.java @@ -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); + } +} diff --git a/core/src/test/java/com/loremind/domain/generationcontext/GenerationResultTest.java b/core/src/test/java/com/loremind/domain/generationcontext/GenerationResultTest.java new file mode 100644 index 0000000..6917dcc --- /dev/null +++ b/core/src/test/java/com/loremind/domain/generationcontext/GenerationResultTest.java @@ -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); + } +} diff --git a/core/src/test/java/com/loremind/domain/generationcontext/LoreStructuralContextTest.java b/core/src/test/java/com/loremind/domain/generationcontext/LoreStructuralContextTest.java new file mode 100644 index 0000000..9b6e3d0 --- /dev/null +++ b/core/src/test/java/com/loremind/domain/generationcontext/LoreStructuralContextTest.java @@ -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()); + } +} diff --git a/core/src/test/java/com/loremind/domain/generationcontext/NarrativeEntityContextTest.java b/core/src/test/java/com/loremind/domain/generationcontext/NarrativeEntityContextTest.java new file mode 100644 index 0000000..42657f3 --- /dev/null +++ b/core/src/test/java/com/loremind/domain/generationcontext/NarrativeEntityContextTest.java @@ -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 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 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); + } +} diff --git a/core/src/test/java/com/loremind/domain/generationcontext/PageContextTest.java b/core/src/test/java/com/loremind/domain/generationcontext/PageContextTest.java new file mode 100644 index 0000000..c6348e5 --- /dev/null +++ b/core/src/test/java/com/loremind/domain/generationcontext/PageContextTest.java @@ -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()); + } +} diff --git a/core/src/test/java/com/loremind/domain/images/ImageTest.java b/core/src/test/java/com/loremind/domain/images/ImageTest.java new file mode 100644 index 0000000..a25f633 --- /dev/null +++ b/core/src/test/java/com/loremind/domain/images/ImageTest.java @@ -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()); + } + } +} diff --git a/core/src/test/java/com/loremind/domain/lorecontext/LoreNodeTest.java b/core/src/test/java/com/loremind/domain/lorecontext/LoreNodeTest.java new file mode 100644 index 0000000..6b60bd9 --- /dev/null +++ b/core/src/test/java/com/loremind/domain/lorecontext/LoreNodeTest.java @@ -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()); + } +} diff --git a/core/src/test/java/com/loremind/domain/lorecontext/LoreTest.java b/core/src/test/java/com/loremind/domain/lorecontext/LoreTest.java new file mode 100644 index 0000000..e9db838 --- /dev/null +++ b/core/src/test/java/com/loremind/domain/lorecontext/LoreTest.java @@ -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()); + } +} diff --git a/core/src/test/java/com/loremind/domain/lorecontext/PageTest.java b/core/src/test/java/com/loremind/domain/lorecontext/PageTest.java new file mode 100644 index 0000000..b17520f --- /dev/null +++ b/core/src/test/java/com/loremind/domain/lorecontext/PageTest.java @@ -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()); + } +} diff --git a/core/src/test/java/com/loremind/domain/lorecontext/TemplateFieldTest.java b/core/src/test/java/com/loremind/domain/lorecontext/TemplateFieldTest.java new file mode 100644 index 0000000..7e77e8a --- /dev/null +++ b/core/src/test/java/com/loremind/domain/lorecontext/TemplateFieldTest.java @@ -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()); + } +} diff --git a/core/src/test/java/com/loremind/domain/lorecontext/TemplateTest.java b/core/src/test/java/com/loremind/domain/lorecontext/TemplateTest.java new file mode 100644 index 0000000..8f98052 --- /dev/null +++ b/core/src/test/java/com/loremind/domain/lorecontext/TemplateTest.java @@ -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 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()); + } +} diff --git a/core/src/test/java/com/loremind/domain/shared/CollectionUtilsTest.java b/core/src/test/java/com/loremind/domain/shared/CollectionUtilsTest.java new file mode 100644 index 0000000..e821c32 --- /dev/null +++ b/core/src/test/java/com/loremind/domain/shared/CollectionUtilsTest.java @@ -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 copy = CollectionUtils.copyMap(null); + assertTrue(copy.isEmpty()); + } + + @Test + void copyMap_returnsDefensiveCopy_distinctFromSource() { + Map source = Map.of("a", 1, "b", 2); + Map copy = CollectionUtils.copyMap(source); + + assertEquals(source, copy); + assertNotSame(source, copy, "La copie doit etre un objet distinct"); + } + + @Test + void copyMap_mutatingCopyDoesNotAffectSource() { + Map source = new HashMap<>(); + source.put("a", 1); + Map 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 copy = CollectionUtils.copyList(null); + assertTrue(copy.isEmpty()); + } + + @Test + void copyList_returnsDefensiveCopy_distinctFromSource() { + List source = List.of("x", "y", "z"); + List copy = CollectionUtils.copyList(source); + + assertEquals(source, copy); + assertNotSame(source, copy); + } + + @Test + void copyList_mutatingCopyDoesNotAffectSource() { + List source = new java.util.ArrayList<>(List.of("a")); + List copy = CollectionUtils.copyList(source); + copy.add("b"); + + assertEquals(1, source.size()); + assertEquals(2, copy.size()); + } + + @Test + void copyList_preservesOrder() { + List source = List.of("zebre", "alpha", "mousse"); + List copy = CollectionUtils.copyList(source); + assertEquals(List.of("zebre", "alpha", "mousse"), copy); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilderTest.java b/core/src/test/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilderTest.java new file mode 100644 index 0000000..bc699c4 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilderTest.java @@ -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 sampleMessages = List.of( + new ChatMessage("user", "Bonjour"), + new ChatMessage("assistant", "Salut")); + + /** Helper : cast generique d'un Object vers Map. Evite les chaines de casts illisibles. */ + @SuppressWarnings("unchecked") + private static Map asMap(Object o) { + return (Map) o; + } + + /** Helper : recupere le premier element d'une liste-de-maps imbriquee sous une cle. */ + @SuppressWarnings("unchecked") + private static Map firstOf(Map parent, String key) { + return ((List>) parent.get(key)).get(0); + } + + // ---------- messages + omission des contextes null --------------------- + + @Test + void build_withMessagesOnly_omitsAllOptionalContexts() { + ChatRequest req = ChatRequest.builder().messages(sampleMessages).build(); + + Map 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 payload = builder.build(req); + + List> messages = (List>) 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 payload = builder.build(req); + + Map lctx = (Map) 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 payload = builder.build(req); + + Map lctx = (Map) payload.get("lore_context"); + Map>> folders = (Map>>) lctx.get("folders"); + Map 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 payload = builder.build(req); + Map lctx = (Map) payload.get("lore_context"); + Map>> folders = (Map>>) lctx.get("folders"); + Map 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 payload = builder.build(req); + Map pctx = (Map) 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 payload = builder.build(req); + + Map cctx = (Map) payload.get("campaign_context"); + assertEquals("Les Ombres", cctx.get("campaign_name")); + List> arcs = (List>) cctx.get("arcs"); + Map arcMap = arcs.get(0); + assertEquals("Acte I", arcMap.get("name")); + assertEquals(1, arcMap.get("illustration_count")); + + List> chapters = (List>) arcMap.get("chapters"); + Map chapterMap = chapters.get(0); + assertEquals("L'arrivee", chapterMap.get("name")); + + List> scenes = (List>) chapterMap.get("scenes"); + Map sceneMap = scenes.get(0); + assertEquals("L'auberge", sceneMap.get("name")); + assertEquals(3, sceneMap.get("illustration_count")); + + List> branches = (List>) sceneMap.get("branches"); + Map 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 payload = builder.build(req); + Map 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 payload = builder.build(req); + Map arcMap = firstOf(asMap(payload.get("campaign_context")), "arcs"); + Map chapterMap = firstOf(arcMap, "chapters"); + Map 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 payload = builder.build(req); + Map arcMap = firstOf(asMap(payload.get("campaign_context")), "arcs"); + Map chapterMap = firstOf(arcMap, "chapters"); + Map sceneMap = firstOf(chapterMap, "scenes"); + Map 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 payload = builder.build(req); + Map ne = (Map) 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 payload = builder.build(req); + + assertTrue(payload.containsKey("campaign_context")); + assertTrue(payload.containsKey("narrative_entity")); + assertFalse(payload.containsKey("lore_context")); + assertFalse(payload.containsKey("page_context")); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/ai/BrainSseParserTest.java b/core/src/test/java/com/loremind/infrastructure/ai/BrainSseParserTest.java new file mode 100644 index 0000000..9ff43a1 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/ai/BrainSseParserTest.java @@ -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\":\"\"}")); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/converter/MapJsonConverterTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/converter/MapJsonConverterTest.java new file mode 100644 index 0000000..003aa48 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/converter/MapJsonConverterTest.java @@ -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 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 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 source = Map.of("s", "hello", "n", 7); + String json = converter.convertToDatabaseColumn(source); + assertEquals(source, converter.convertToEntityAttribute(json)); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/converter/SceneBranchListJsonConverterTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/converter/SceneBranchListJsonConverterTest.java new file mode 100644 index 0000000..57af586 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/converter/SceneBranchListJsonConverterTest.java @@ -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 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 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"); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/converter/StringListJsonConverterTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/converter/StringListJsonConverterTest.java new file mode 100644 index 0000000..b79f643 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/converter/StringListJsonConverterTest.java @@ -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 source = List.of("pnj", "allie", "royaume"); + String json = converter.convertToDatabaseColumn(source); + assertEquals(source, converter.convertToEntityAttribute(json)); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/converter/StringListMapJsonConverterTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/converter/StringListMapJsonConverterTest.java new file mode 100644 index 0000000..8a4f24e --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/converter/StringListMapJsonConverterTest.java @@ -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> — 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> 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> source = Map.of( + "Portrait", List.of("42", "17"), + "Carte", List.of("99") + ); + String json = converter.convertToDatabaseColumn(source); + Map> 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)"); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/converter/StringMapJsonConverterTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/converter/StringMapJsonConverterTest.java new file mode 100644 index 0000000..9c1fd0b --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/converter/StringMapJsonConverterTest.java @@ -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 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 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 source = Map.of("histoire", "Nee sous une etoile rouge", "motto", "Jamais"); + String json = converter.convertToDatabaseColumn(source); + assertEquals(source, converter.convertToEntityAttribute(json)); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverterTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverterTest.java new file mode 100644 index 0000000..6d901a9 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverterTest.java @@ -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 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 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 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 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 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 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 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 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 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 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 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 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 pass1 = converter.convertToEntityAttribute("[\"A\",\"B\"]"); + String rewritten = converter.convertToDatabaseColumn(pass1); + List 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()); + } + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepositoryTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepositoryTest.java new file mode 100644 index 0000000..713f54d --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepositoryTest.java @@ -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()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepositoryTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepositoryTest.java new file mode 100644 index 0000000..48b7afc --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepositoryTest.java @@ -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 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()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepositoryTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepositoryTest.java new file mode 100644 index 0000000..f98a8f1 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepositoryTest.java @@ -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())); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresConversationRepositoryTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresConversationRepositoryTest.java new file mode 100644 index 0000000..4892930 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresConversationRepositoryTest.java @@ -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 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 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 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()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresImageRepositoryTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresImageRepositoryTest.java new file mode 100644 index 0000000..7fe0c11 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresImageRepositoryTest.java @@ -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")); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresLoreNodeRepositoryTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresLoreNodeRepositoryTest.java new file mode 100644 index 0000000..c77fec9 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresLoreNodeRepositoryTest.java @@ -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 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 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 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()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepositoryTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepositoryTest.java new file mode 100644 index 0000000..78ae6d4 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepositoryTest.java @@ -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 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(); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepositoryTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepositoryTest.java new file mode 100644 index 0000000..471de25 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepositoryTest.java @@ -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())); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresTemplateRepositoryTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresTemplateRepositoryTest.java new file mode 100644 index 0000000..9d9d254 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresTemplateRepositoryTest.java @@ -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