diff --git a/brain/app/application/chat.py b/brain/app/application/chat.py index fae44e8..4466b3e 100644 --- a/brain/app/application/chat.py +++ b/brain/app/application/chat.py @@ -179,7 +179,9 @@ class ChatUseCase: return ( "--- CAMPAGNE COURANTE ---\n" f"Nom : {ctx.campaign_name}{desc}{lore_note}\n\n" - f"Structure narrative :\n{arcs_block}" + "Structure narrative (les flèches → indiquent des transitions de scène " + "déclenchées par un choix des joueurs) :\n" + f"{arcs_block}" ) @staticmethod @@ -212,6 +214,11 @@ class ChatUseCase: block.append(f" - {scene.name} (scène){sc_hint}") if scene.description: block.append(f" Description : {scene.description}") + for br in scene.branches: + cond = f" (si : {br.condition})" if br.condition else "" + block.append( + f' → "{br.label}" vers {br.target_scene_name}{cond}' + ) return block @staticmethod diff --git a/brain/app/domain/models.py b/brain/app/domain/models.py index 7e861dc..ec17322 100644 --- a/brain/app/domain/models.py +++ b/brain/app/domain/models.py @@ -4,7 +4,7 @@ On utilise @dataclass (pas Pydantic) pour garder le domaine exempt de toute dépendance framework. Pydantic apparaît uniquement aux frontières : DTOs HTTP dans `main.py`, Settings dans `core/config.py`. """ -from dataclasses import dataclass +from dataclasses import dataclass, field @dataclass(frozen=True) @@ -109,15 +109,30 @@ class PageContext: values: dict[str, str] +@dataclass(frozen=True) +class SceneBranchHint: + """Indice d'une branche narrative vers une autre scène du même chapitre. + + Le Core Java résout déjà `targetSceneId` en nom humain avant l'envoi : + l'IA ne voit donc jamais d'UUID, seulement des noms qu'elle peut citer. + """ + + label: str + target_scene_name: str + condition: str | None = None + + @dataclass(frozen=True) class SceneSummary: - """Résumé d'une scène : nom + description courte + nb illustrations.""" + """Résumé d'une scène : nom + description courte + illustrations + branches.""" name: str description: str | None # Depuis l'etape 6 : permet a l'IA de savoir qu'une scene a des illustrations # attachees. 0 par defaut pour retrocompat si le Core n'envoie rien. illustration_count: int = 0 + # Connexions narratives sortantes (livre dont vous etes le heros). + branches: list[SceneBranchHint] = field(default_factory=list) @dataclass(frozen=True) diff --git a/brain/app/main.py b/brain/app/main.py index 6245601..a2a3c0b 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -24,6 +24,7 @@ from app.domain.models import ( PageContext, PageGenerationContext, PageSummary, + SceneBranchHint, SceneSummary, ) from app.domain.ports import LLMProvider, LLMProviderError @@ -105,6 +106,14 @@ class PageContextDTO(BaseModel): values: dict[str, str] = Field(default_factory=dict) +class SceneBranchHintDTO(BaseModel): + """Indice d'une branche narrative (le Core a deja resolu le nom cible).""" + + label: str + target_scene_name: str + condition: str | None = None + + class SceneSummaryDTO(BaseModel): """Résumé d'une scène : nom + description courte (synopsis).""" @@ -113,6 +122,8 @@ class SceneSummaryDTO(BaseModel): # Optionnel : le Core Java ne serialise illustration_count QUE si > 0 # (payload plus leger). Defaut 0 = pas d'illustrations ou champ absent. illustration_count: int = 0 + # Branches narratives sortantes, omises cote Core si vides. + branches: list[SceneBranchHintDTO] = Field(default_factory=list) class ChapterSummaryDTO(BaseModel): @@ -357,6 +368,14 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo name=sc.name, description=sc.description, illustration_count=sc.illustration_count, + branches=[ + SceneBranchHint( + label=br.label, + target_scene_name=br.target_scene_name, + condition=br.condition, + ) + for br in sc.branches + ], ) for sc in ch.scenes ], diff --git a/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java index 44f453e..fb2b154 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java @@ -1,11 +1,14 @@ package com.loremind.application.campaigncontext; import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.SceneBranch; import com.loremind.domain.campaigncontext.ports.SceneRepository; import org.springframework.stereotype.Service; import java.util.List; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; /** * Service d'application pour le contexte Scene. @@ -66,6 +69,11 @@ public class SceneService { scene.setEnemies(updated.getEnemies()); scene.setRelatedPageIds(updated.getRelatedPageIds()); scene.setIllustrationImageIds(updated.getIllustrationImageIds()); + scene.setBranches(updated.getBranches()); + + // Validation métier : le graphe narratif doit rester cohérent. + validateBranches(scene); + return sceneRepository.save(scene); } @@ -76,4 +84,38 @@ public class SceneService { public boolean sceneExists(String id) { return sceneRepository.existsById(id); } + + /** + * Vérifie les invariants du graphe narratif : + * 1. Pas d'auto-référence (scène qui pointe sur elle-même). + * 2. Toutes les branches pointent vers des scènes du MÊME chapitre. + * 3. Pas de targetSceneId null/vide. + * + * Note : on ne vérifie PAS l'existence réelle de chaque scène cible + * individuellement (ça serait un N+1). On charge une seule fois les + * IDs du chapitre et on compare. + */ + private void validateBranches(Scene scene) { + List branches = scene.getBranches(); + if (branches == null || branches.isEmpty()) return; + + // IDs des scènes du chapitre courant (référentiel de validation) + Set chapterSceneIds = sceneRepository.findByChapterId(scene.getChapterId()).stream() + .map(Scene::getId) + .collect(Collectors.toSet()); + + for (SceneBranch b : branches) { + String target = b.getTargetSceneId(); + if (target == null || target.isBlank()) { + throw new IllegalArgumentException("Une branche doit avoir une scène de destination"); + } + if (target.equals(scene.getId())) { + throw new IllegalArgumentException("Une scène ne peut pas se brancher sur elle-même"); + } + if (!chapterSceneIds.contains(target)) { + throw new IllegalArgumentException( + "La branche pointe vers la scène " + target + " qui n'appartient pas au même chapitre"); + } + } + } } diff --git a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java index 0b5ddf8..e00989a 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java +++ b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java @@ -10,12 +10,14 @@ import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint; import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary; import org.springframework.stereotype.Component; import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; /** @@ -84,23 +86,44 @@ public class CampaignStructuralContextBuilder { } private ChapterSummary toChapterSummary(Chapter chapter) { - List scenes = sceneRepository.findByChapterId(chapter.getId()).stream() + List scenes = sceneRepository.findByChapterId(chapter.getId()).stream() .sorted(Comparator.comparingInt(Scene::getOrder)) - .map(this::toSceneSummary) .collect(Collectors.toList()); + + // Map id -> nom construite en une seule passe pour resoudre les + // targetSceneId des branches sans re-interroger le repo (evite N+1). + Map nameById = scenes.stream() + .collect(Collectors.toMap(Scene::getId, Scene::getName)); + + List summaries = scenes.stream() + .map(s -> toSceneSummary(s, nameById)) + .collect(Collectors.toList()); + return ChapterSummary.builder() .name(chapter.getName()) .description(chapter.getDescription()) .illustrationCount(countImages(chapter.getIllustrationImageIds())) - .scenes(scenes) + .scenes(summaries) .build(); } - private SceneSummary toSceneSummary(Scene scene) { + private SceneSummary toSceneSummary(Scene scene, Map nameById) { + List hints = scene.getBranches() == null + ? List.of() + : scene.getBranches().stream() + .map(b -> BranchHint.builder() + .label(b.getLabel()) + .targetSceneName(nameById.getOrDefault( + b.getTargetSceneId(), "(scène inconnue)")) + .condition(b.getCondition()) + .build()) + .collect(Collectors.toList()); + return SceneSummary.builder() .name(scene.getName()) .description(scene.getDescription()) .illustrationCount(countImages(scene.getIllustrationImageIds())) + .branches(hints) .build(); } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java b/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java index 7cf8c52..31ea316 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java @@ -53,6 +53,14 @@ public class Scene { @Builder.Default private List illustrationImageIds = new ArrayList<>(); + /** + * Sorties narratives possibles depuis cette scène (graphe intra-chapitre). + * Chaque branche décrit un choix des joueurs et la scène de destination. + * Liste vide = scène "feuille" (fin de chapitre ou scène linéaire). + */ + @Builder.Default + private List branches = new ArrayList<>(); + private LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -71,4 +79,21 @@ public class Scene { this.updatedAt = LocalDateTime.now(); } } + + public void addBranch(SceneBranch branch) { + if (branch == null) return; + if (branches == null) branches = new ArrayList<>(); + // Interdit l'auto-référence (scène qui pointe sur elle-même) + if (this.id != null && this.id.equals(branch.getTargetSceneId())) { + throw new IllegalArgumentException("Une scène ne peut pas se brancher sur elle-même"); + } + branches.add(branch); + this.updatedAt = LocalDateTime.now(); + } + + public void removeBranchTo(String targetSceneId) { + if (branches == null || targetSceneId == null) return; + boolean removed = branches.removeIf(b -> targetSceneId.equals(b.getTargetSceneId())); + if (removed) this.updatedAt = LocalDateTime.now(); + } } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java b/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java new file mode 100644 index 0000000..fda197d --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java @@ -0,0 +1,31 @@ +package com.loremind.domain.campaigncontext; + +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +/** + * Value Object représentant une "sortie" narrative depuis une Scene. + * Décrit un choix offert aux joueurs et la scène de destination associée. + * + * Immuable (@Value) : pour "modifier" une branche on la remplace. + * @Jacksonized : permet à Jackson (sérialisation JSON via le converter JPA) + * de reconstruire l'objet en passant par le builder malgré l'absence de setters. + * + * Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter + * (validation portée par SceneService). + */ +@Value +@Builder +@Jacksonized +public class SceneBranch { + + /** Libellé du choix (ex: "Si les joueurs attaquent le garde"). */ + String label; + + /** Id de la Scene de destination, intra-chapitre uniquement. */ + String targetSceneId; + + /** Notes MJ privées sur la condition de déclenchement (optionnel). */ + String condition; +} diff --git a/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java b/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java index d1d74d9..42bc385 100644 --- a/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java +++ b/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java @@ -52,12 +52,25 @@ public class CampaignStructuralContext { @Singular List scenes; } - /** Résumé d'une scène : nom + description courte. */ + /** Résumé d'une scène : nom + description courte + branches narratives. */ @Value @Builder public static class SceneSummary { String name; String description; int illustrationCount; + @Singular List branches; + } + + /** Indice d'une branche narrative vers une autre scène du même chapitre. */ + @Value + @Builder + public static class BranchHint { + /** Libellé du choix joueur (ex: "Si les joueurs attaquent le garde"). */ + String label; + /** Nom de la scène cible (résolu depuis targetSceneId côté builder). */ + String targetSceneName; + /** Condition MJ privée (optionnel). */ + String condition; } } 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 ee1e7a7..d0aaddd 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java @@ -2,6 +2,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; @@ -253,6 +254,23 @@ public class BrainAiChatClient implements AiChatProvider { if (s.getIllustrationCount() > 0) { map.put("illustration_count", s.getIllustrationCount()); } + // 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())); + } + return map; + } + + 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; } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/converter/SceneBranchListJsonConverter.java b/core/src/main/java/com/loremind/infrastructure/persistence/converter/SceneBranchListJsonConverter.java new file mode 100644 index 0000000..9937e14 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/converter/SceneBranchListJsonConverter.java @@ -0,0 +1,48 @@ +package com.loremind.infrastructure.persistence.converter; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.SceneBranch; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.util.Collections; +import java.util.List; + +/** + * Convertit une List du domaine en chaîne JSON stockée en base, + * et inversement. Même pattern que StringListJsonConverter mais typé sur + * le Value Object SceneBranch. + * + * Adaptateur d'infrastructure : le domaine reste pur (List) + * pendant que PostgreSQL reçoit un TEXT JSON. + */ +@Converter +public class SceneBranchListJsonConverter implements AttributeConverter, String> { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null || attribute.isEmpty()) { + return "[]"; + } + try { + return MAPPER.writeValueAsString(attribute); + } catch (Exception e) { + throw new IllegalStateException("Erreur sérialisation List → JSON", e); + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) { + return Collections.emptyList(); + } + try { + return MAPPER.readValue(dbData, new TypeReference>() {}); + } catch (Exception e) { + throw new IllegalStateException("Erreur désérialisation JSON → List", e); + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java index b341e59..af0c146 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java @@ -1,5 +1,7 @@ package com.loremind.infrastructure.persistence.entity; +import com.loremind.domain.campaigncontext.SceneBranch; +import com.loremind.infrastructure.persistence.converter.SceneBranchListJsonConverter; import com.loremind.infrastructure.persistence.converter.StringListJsonConverter; import jakarta.persistence.*; import lombok.AllArgsConstructor; @@ -78,6 +80,13 @@ public class SceneJpaEntity { @Builder.Default private List illustrationImageIds = new ArrayList<>(); + // Graphe narratif intra-chapitre : sorties possibles vers d'autres scènes. + // Persisté en TEXT JSON via converter (pattern homogène avec les autres listes). + @Column(name = "branches", columnDefinition = "TEXT") + @Convert(converter = SceneBranchListJsonConverter.class) + @Builder.Default + private List branches = new ArrayList<>(); + @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java index 10ed3b2..875a9c2 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java @@ -85,6 +85,9 @@ public class PostgresSceneRepository implements SceneRepository { .illustrationImageIds(jpaEntity.getIllustrationImageIds() != null ? new ArrayList<>(jpaEntity.getIllustrationImageIds()) : new ArrayList<>()) + .branches(jpaEntity.getBranches() != null + ? new ArrayList<>(jpaEntity.getBranches()) + : new ArrayList<>()) .createdAt(jpaEntity.getCreatedAt()) .updatedAt(jpaEntity.getUpdatedAt()) .build(); @@ -112,6 +115,9 @@ public class PostgresSceneRepository implements SceneRepository { .illustrationImageIds(scene.getIllustrationImageIds() != null ? new ArrayList<>(scene.getIllustrationImageIds()) : new ArrayList<>()) + .branches(scene.getBranches() != null + ? new ArrayList<>(scene.getBranches()) + : new ArrayList<>()) .createdAt(scene.getCreatedAt()) .updatedAt(scene.getUpdatedAt()) .build(); diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneBranchDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneBranchDTO.java new file mode 100644 index 0000000..c0288e0 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneBranchDTO.java @@ -0,0 +1,22 @@ +package com.loremind.infrastructure.web.dto.campaigncontext; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +/** + * DTO pour une branche narrative (sortie) depuis une Scene. + * Pendant web du Value Object domaine SceneBranch. + * + * @Data (mutable) est approprié pour les DTO de wire : Jackson désérialise + * via setters côté requête entrante. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SceneBranchDTO { + + private String label; + private String targetSceneId; + private String condition; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java index 722c37f..c3cec12 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java @@ -32,4 +32,7 @@ public class SceneDTO { /** IDs des images (Shared Kernel) illustrant cette scene. */ private List illustrationImageIds = new ArrayList<>(); + + /** Branches narratives : sorties possibles vers d'autres scènes du même chapitre. */ + private List branches = new ArrayList<>(); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java index 3168c8d..6bd0501 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java @@ -1,10 +1,14 @@ package com.loremind.infrastructure.web.mapper; import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.SceneBranch; +import com.loremind.infrastructure.web.dto.campaigncontext.SceneBranchDTO; import com.loremind.infrastructure.web.dto.campaigncontext.SceneDTO; import org.springframework.stereotype.Component; import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; /** * Mapper pour convertir entre Scene (entité de domaine) et SceneDTO. @@ -37,6 +41,7 @@ public class SceneMapper { dto.setIllustrationImageIds(scene.getIllustrationImageIds() != null ? new ArrayList<>(scene.getIllustrationImageIds()) : new ArrayList<>()); + dto.setBranches(toBranchDTOs(scene.getBranches())); return dto; } @@ -65,6 +70,27 @@ public class SceneMapper { .illustrationImageIds(dto.getIllustrationImageIds() != null ? new ArrayList<>(dto.getIllustrationImageIds()) : new ArrayList<>()) + .branches(toBranchDomain(dto.getBranches())) .build(); } + + // ─────────────── Mapping des branches (VO <-> DTO) ─────────────── + + private List toBranchDTOs(List branches) { + if (branches == null) return new ArrayList<>(); + return branches.stream() + .map(b -> new SceneBranchDTO(b.getLabel(), b.getTargetSceneId(), b.getCondition())) + .collect(Collectors.toList()); + } + + private List toBranchDomain(List dtos) { + if (dtos == null) return new ArrayList<>(); + return dtos.stream() + .map(d -> SceneBranch.builder() + .label(d.getLabel()) + .targetSceneId(d.getTargetSceneId()) + .condition(d.getCondition()) + .build()) + .collect(Collectors.toList()); + } } diff --git a/core/src/test/java/com/loremind/application/campaigncontext/ArcServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/ArcServiceTest.java new file mode 100644 index 0000000..8832e46 --- /dev/null +++ b/core/src/test/java/com/loremind/application/campaigncontext/ArcServiceTest.java @@ -0,0 +1,198 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.ports.ArcRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +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.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour ArcService. + * Utilise des mocks pour les ports de sortie (ArcRepository). + * Teste la logique d'orchestration de la couche Application. + */ +@ExtendWith(MockitoExtension.class) +public class ArcServiceTest { + + @Mock + private ArcRepository arcRepository; + + @InjectMocks + private ArcService arcService; + + private Arc testArc; + + @BeforeEach + void setUp() { + testArc = Arc.builder() + .id("arc-1") + .name("Test Arc") + .description("Test Description") + .campaignId("campaign-1") + .order(1) + .build(); + } + + @Test + void testCreateArc() { + // Arrange + when(arcRepository.save(any(Arc.class))).thenReturn(testArc); + + // Act + Arc result = arcService.createArc("New Arc", "Description", "campaign-1", 1); + + // Assert + assertNotNull(result); + verify(arcRepository, times(1)).save(any(Arc.class)); + } + + @Test + void testGetArcById_Found() { + // Arrange + when(arcRepository.findById("arc-1")).thenReturn(Optional.of(testArc)); + + // Act + Optional result = arcService.getArcById("arc-1"); + + // Assert + assertTrue(result.isPresent()); + assertEquals("Test Arc", result.get().getName()); + verify(arcRepository, times(1)).findById("arc-1"); + } + + @Test + void testGetArcById_NotFound() { + // Arrange + when(arcRepository.findById("invalid-id")).thenReturn(Optional.empty()); + + // Act + Optional result = arcService.getArcById("invalid-id"); + + // Assert + assertFalse(result.isPresent()); + verify(arcRepository, times(1)).findById("invalid-id"); + } + + @Test + void testGetAllArcs() { + // Arrange + Arc arc2 = Arc.builder() + .id("arc-2") + .name("Arc 2") + .build(); + when(arcRepository.findAll()).thenReturn(List.of(testArc, arc2)); + + // Act + List result = arcService.getAllArcs(); + + // Assert + assertEquals(2, result.size()); + verify(arcRepository, times(1)).findAll(); + } + + @Test + void testGetArcsByCampaignId() { + // Arrange + when(arcRepository.findByCampaignId("campaign-1")).thenReturn(List.of(testArc)); + + // Act + List result = arcService.getArcsByCampaignId("campaign-1"); + + // Assert + assertEquals(1, result.size()); + verify(arcRepository, times(1)).findByCampaignId("campaign-1"); + } + + @Test + void testUpdateArc_Success() { + // Arrange + Arc updatedArc = Arc.builder() + .name("Updated Arc") + .description("Updated Description") + .order(2) + .themes("Theme1, Theme2") + .stakes("High stakes") + .gmNotes("GM notes") + .rewards("Rewards") + .resolution("Resolution") + .relatedPageIds(List.of("page-1")) + .illustrationImageIds(List.of("image-1")) + .build(); + when(arcRepository.findById("arc-1")).thenReturn(Optional.of(testArc)); + when(arcRepository.save(any(Arc.class))).thenReturn(testArc); + + // Act + Arc result = arcService.updateArc("arc-1", updatedArc); + + // Assert + assertNotNull(result); + verify(arcRepository, times(1)).findById("arc-1"); + verify(arcRepository, times(1)).save(any(Arc.class)); + } + + @Test + void testUpdateArc_NotFound() { + // Arrange + Arc updatedArc = Arc.builder() + .name("Updated Arc") + .build(); + when(arcRepository.findById("invalid-id")).thenReturn(Optional.empty()); + + // Act & Assert + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> arcService.updateArc("invalid-id", updatedArc) + ); + assertEquals("Arc non trouvé avec l'ID: invalid-id", exception.getMessage()); + verify(arcRepository, times(1)).findById("invalid-id"); + verify(arcRepository, never()).save(any()); + } + + @Test + void testDeleteArc() { + // Arrange + doNothing().when(arcRepository).deleteById("arc-1"); + + // Act + arcService.deleteArc("arc-1"); + + // Assert + verify(arcRepository, times(1)).deleteById("arc-1"); + } + + @Test + void testArcExists_True() { + // Arrange + when(arcRepository.existsById("arc-1")).thenReturn(true); + + // Act + boolean result = arcService.arcExists("arc-1"); + + // Assert + assertTrue(result); + verify(arcRepository, times(1)).existsById("arc-1"); + } + + @Test + void testArcExists_False() { + // Arrange + when(arcRepository.existsById("invalid-id")).thenReturn(false); + + // Act + boolean result = arcService.arcExists("invalid-id"); + + // Assert + assertFalse(result); + verify(arcRepository, times(1)).existsById("invalid-id"); + } +} diff --git a/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java new file mode 100644 index 0000000..67fabc0 --- /dev/null +++ b/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java @@ -0,0 +1,271 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +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.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour CampaignService. + * Utilise des mocks pour les ports de sortie (CampaignRepository). + * Teste la logique d'orchestration de la couche Application. + */ +@ExtendWith(MockitoExtension.class) +public class CampaignServiceTest { + + @Mock + private CampaignRepository campaignRepository; + + @InjectMocks + private CampaignService campaignService; + + private Campaign testCampaign; + + @BeforeEach + void setUp() { + testCampaign = Campaign.builder() + .id("campaign-1") + .name("Test Campaign") + .description("Test Description") + .loreId("lore-1") + .arcsCount(0) + .build(); + } + + @Test + void testCreateCampaign_WithLoreId() { + // Arrange + CampaignService.CampaignData data = new CampaignService.CampaignData( + "New Campaign", + "Description", + "lore-123" + ); + when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign); + + // Act + Campaign result = campaignService.createCampaign(data); + + // Assert + assertNotNull(result); + verify(campaignRepository, times(1)).save(any(Campaign.class)); + assertEquals("lore-123", result.getLoreId()); + } + + @Test + void testCreateCampaign_WithNullLoreId() { + // Arrange + CampaignService.CampaignData data = new CampaignService.CampaignData( + "New Campaign", + "Description", + null + ); + when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign); + + // Act + Campaign result = campaignService.createCampaign(data); + + // Assert + assertNotNull(result); + verify(campaignRepository, times(1)).save(any(Campaign.class)); + assertNull(result.getLoreId()); + } + + @Test + void testCreateCampaign_WithBlankLoreId() { + // Arrange + CampaignService.CampaignData data = new CampaignService.CampaignData( + "New Campaign", + "Description", + " " + ); + when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign); + + // Act + Campaign result = campaignService.createCampaign(data); + + // Assert + assertNotNull(result); + verify(campaignRepository, times(1)).save(any(Campaign.class)); + assertNull(result.getLoreId()); + } + + @Test + void testGetCampaignById_Found() { + // Arrange + when(campaignRepository.findById("campaign-1")).thenReturn(Optional.of(testCampaign)); + + // Act + Optional result = campaignService.getCampaignById("campaign-1"); + + // Assert + assertTrue(result.isPresent()); + assertEquals("Test Campaign", result.get().getName()); + verify(campaignRepository, times(1)).findById("campaign-1"); + } + + @Test + void testGetCampaignById_NotFound() { + // Arrange + when(campaignRepository.findById("invalid-id")).thenReturn(Optional.empty()); + + // Act + Optional result = campaignService.getCampaignById("invalid-id"); + + // Assert + assertFalse(result.isPresent()); + verify(campaignRepository, times(1)).findById("invalid-id"); + } + + @Test + void testGetAllCampaigns() { + // Arrange + Campaign campaign2 = Campaign.builder() + .id("campaign-2") + .name("Campaign 2") + .build(); + when(campaignRepository.findAll()).thenReturn(List.of(testCampaign, campaign2)); + + // Act + List result = campaignService.getAllCampaigns(); + + // Assert + assertEquals(2, result.size()); + verify(campaignRepository, times(1)).findAll(); + } + + @Test + void testUpdateCampaign_Success() { + // Arrange + CampaignService.CampaignData data = new CampaignService.CampaignData( + "Updated Campaign", + "Updated Description", + "lore-456" + ); + when(campaignRepository.findById("campaign-1")).thenReturn(Optional.of(testCampaign)); + when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign); + + // Act + Campaign result = campaignService.updateCampaign("campaign-1", data); + + // Assert + assertNotNull(result); + verify(campaignRepository, times(1)).findById("campaign-1"); + verify(campaignRepository, times(1)).save(any(Campaign.class)); + } + + @Test + void testUpdateCampaign_NotFound() { + // Arrange + CampaignService.CampaignData data = new CampaignService.CampaignData( + "Updated Campaign", + "Updated Description", + "lore-456" + ); + when(campaignRepository.findById("invalid-id")).thenReturn(Optional.empty()); + + // Act & Assert + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> campaignService.updateCampaign("invalid-id", data) + ); + assertEquals("Campaign non trouvé avec l'ID: invalid-id", exception.getMessage()); + verify(campaignRepository, times(1)).findById("invalid-id"); + verify(campaignRepository, never()).save(any()); + } + + @Test + void testDeleteCampaign() { + // Arrange + doNothing().when(campaignRepository).deleteById("campaign-1"); + + // Act + campaignService.deleteCampaign("campaign-1"); + + // Assert + verify(campaignRepository, times(1)).deleteById("campaign-1"); + } + + @Test + void testCampaignExists_True() { + // Arrange + when(campaignRepository.existsById("campaign-1")).thenReturn(true); + + // Act + boolean result = campaignService.campaignExists("campaign-1"); + + // Assert + assertTrue(result); + verify(campaignRepository, times(1)).existsById("campaign-1"); + } + + @Test + void testCampaignExists_False() { + // Arrange + when(campaignRepository.existsById("invalid-id")).thenReturn(false); + + // Act + boolean result = campaignService.campaignExists("invalid-id"); + + // Assert + assertFalse(result); + verify(campaignRepository, times(1)).existsById("invalid-id"); + } + + @Test + void testSearchCampaigns_WithValidQuery() { + // Arrange + when(campaignRepository.searchByName("Test")).thenReturn(List.of(testCampaign)); + + // Act + List result = campaignService.searchCampaigns("Test"); + + // Assert + assertEquals(1, result.size()); + verify(campaignRepository, times(1)).searchByName("Test"); + } + + @Test + void testSearchCampaigns_WithNullQuery() { + // Act + List result = campaignService.searchCampaigns(null); + + // Assert + assertTrue(result.isEmpty()); + verify(campaignRepository, never()).searchByName(anyString()); + } + + @Test + void testSearchCampaigns_WithBlankQuery() { + // Act + List result = campaignService.searchCampaigns(" "); + + // Assert + assertTrue(result.isEmpty()); + verify(campaignRepository, never()).searchByName(anyString()); + } + + @Test + void testSearchCampaigns_WithTrim() { + // Arrange + when(campaignRepository.searchByName("Test")).thenReturn(List.of(testCampaign)); + + // Act + List result = campaignService.searchCampaigns(" Test "); + + // Assert + assertEquals(1, result.size()); + verify(campaignRepository, times(1)).searchByName("Test"); + } +} diff --git a/core/src/test/java/com/loremind/application/campaigncontext/ChapterServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/ChapterServiceTest.java new file mode 100644 index 0000000..53f11a5 --- /dev/null +++ b/core/src/test/java/com/loremind/application/campaigncontext/ChapterServiceTest.java @@ -0,0 +1,196 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +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.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour ChapterService. + * Utilise des mocks pour les ports de sortie (ChapterRepository). + * Teste la logique d'orchestration de la couche Application. + */ +@ExtendWith(MockitoExtension.class) +public class ChapterServiceTest { + + @Mock + private ChapterRepository chapterRepository; + + @InjectMocks + private ChapterService chapterService; + + private Chapter testChapter; + + @BeforeEach + void setUp() { + testChapter = Chapter.builder() + .id("chapter-1") + .name("Test Chapter") + .description("Test Description") + .arcId("arc-1") + .order(1) + .build(); + } + + @Test + void testCreateChapter() { + // Arrange + when(chapterRepository.save(any(Chapter.class))).thenReturn(testChapter); + + // Act + Chapter result = chapterService.createChapter("New Chapter", "Description", "arc-1", 1); + + // Assert + assertNotNull(result); + verify(chapterRepository, times(1)).save(any(Chapter.class)); + } + + @Test + void testGetChapterById_Found() { + // Arrange + when(chapterRepository.findById("chapter-1")).thenReturn(Optional.of(testChapter)); + + // Act + Optional result = chapterService.getChapterById("chapter-1"); + + // Assert + assertTrue(result.isPresent()); + assertEquals("Test Chapter", result.get().getName()); + verify(chapterRepository, times(1)).findById("chapter-1"); + } + + @Test + void testGetChapterById_NotFound() { + // Arrange + when(chapterRepository.findById("invalid-id")).thenReturn(Optional.empty()); + + // Act + Optional result = chapterService.getChapterById("invalid-id"); + + // Assert + assertFalse(result.isPresent()); + verify(chapterRepository, times(1)).findById("invalid-id"); + } + + @Test + void testGetAllChapters() { + // Arrange + Chapter chapter2 = Chapter.builder() + .id("chapter-2") + .name("Chapter 2") + .build(); + when(chapterRepository.findAll()).thenReturn(List.of(testChapter, chapter2)); + + // Act + List result = chapterService.getAllChapters(); + + // Assert + assertEquals(2, result.size()); + verify(chapterRepository, times(1)).findAll(); + } + + @Test + void testGetChaptersByArcId() { + // Arrange + when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(testChapter)); + + // Act + List result = chapterService.getChaptersByArcId("arc-1"); + + // Assert + assertEquals(1, result.size()); + verify(chapterRepository, times(1)).findByArcId("arc-1"); + } + + @Test + void testUpdateChapter_Success() { + // Arrange + Chapter updatedChapter = Chapter.builder() + .name("Updated Chapter") + .description("Updated Description") + .order(2) + .gmNotes("GM notes") + .playerObjectives("Objectives") + .narrativeStakes("Stakes") + .relatedPageIds(List.of("page-1")) + .illustrationImageIds(List.of("image-1")) + .build(); + when(chapterRepository.findById("chapter-1")).thenReturn(Optional.of(testChapter)); + when(chapterRepository.save(any(Chapter.class))).thenReturn(testChapter); + + // Act + Chapter result = chapterService.updateChapter("chapter-1", updatedChapter); + + // Assert + assertNotNull(result); + verify(chapterRepository, times(1)).findById("chapter-1"); + verify(chapterRepository, times(1)).save(any(Chapter.class)); + } + + @Test + void testUpdateChapter_NotFound() { + // Arrange + Chapter updatedChapter = Chapter.builder() + .name("Updated Chapter") + .build(); + when(chapterRepository.findById("invalid-id")).thenReturn(Optional.empty()); + + // Act & Assert + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> chapterService.updateChapter("invalid-id", updatedChapter) + ); + assertEquals("Chapter non trouvé avec l'ID: invalid-id", exception.getMessage()); + verify(chapterRepository, times(1)).findById("invalid-id"); + verify(chapterRepository, never()).save(any()); + } + + @Test + void testDeleteChapter() { + // Arrange + doNothing().when(chapterRepository).deleteById("chapter-1"); + + // Act + chapterService.deleteChapter("chapter-1"); + + // Assert + verify(chapterRepository, times(1)).deleteById("chapter-1"); + } + + @Test + void testChapterExists_True() { + // Arrange + when(chapterRepository.existsById("chapter-1")).thenReturn(true); + + // Act + boolean result = chapterService.chapterExists("chapter-1"); + + // Assert + assertTrue(result); + verify(chapterRepository, times(1)).existsById("chapter-1"); + } + + @Test + void testChapterExists_False() { + // Arrange + when(chapterRepository.existsById("invalid-id")).thenReturn(false); + + // Act + boolean result = chapterService.chapterExists("invalid-id"); + + // Assert + assertFalse(result); + verify(chapterRepository, times(1)).existsById("invalid-id"); + } +} diff --git a/core/src/test/java/com/loremind/application/campaigncontext/SceneServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/SceneServiceTest.java new file mode 100644 index 0000000..14ecdf4 --- /dev/null +++ b/core/src/test/java/com/loremind/application/campaigncontext/SceneServiceTest.java @@ -0,0 +1,358 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Scene; +import com.loremind.domain.campaigncontext.SceneBranch; +import com.loremind.domain.campaigncontext.ports.SceneRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +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.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour SceneService. + * Utilise des mocks pour les ports de sortie (SceneRepository). + * Teste la logique d'orchestration de la couche Application. + */ +@ExtendWith(MockitoExtension.class) +public class SceneServiceTest { + + @Mock + private SceneRepository sceneRepository; + + @InjectMocks + private SceneService sceneService; + + private Scene testScene; + private Scene scene2; + private Scene scene3; + + @BeforeEach + void setUp() { + testScene = Scene.builder() + .id("scene-1") + .name("Test Scene") + .description("Test Description") + .chapterId("chapter-1") + .order(1) + .build(); + + scene2 = Scene.builder() + .id("scene-2") + .name("Scene 2") + .chapterId("chapter-1") + .order(2) + .build(); + + scene3 = Scene.builder() + .id("scene-3") + .name("Scene 3") + .chapterId("chapter-1") + .order(3) + .build(); + } + + @Test + void testCreateScene() { + // Arrange + when(sceneRepository.save(any(Scene.class))).thenReturn(testScene); + + // Act + Scene result = sceneService.createScene("New Scene", "Description", "chapter-1", 1); + + // Assert + assertNotNull(result); + verify(sceneRepository, times(1)).save(any(Scene.class)); + } + + @Test + void testGetSceneById_Found() { + // Arrange + when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(testScene)); + + // Act + Optional result = sceneService.getSceneById("scene-1"); + + // Assert + assertTrue(result.isPresent()); + assertEquals("Test Scene", result.get().getName()); + verify(sceneRepository, times(1)).findById("scene-1"); + } + + @Test + void testGetSceneById_NotFound() { + // Arrange + when(sceneRepository.findById("invalid-id")).thenReturn(Optional.empty()); + + // Act + Optional result = sceneService.getSceneById("invalid-id"); + + // Assert + assertFalse(result.isPresent()); + verify(sceneRepository, times(1)).findById("invalid-id"); + } + + @Test + void testGetAllScenes() { + // Arrange + when(sceneRepository.findAll()).thenReturn(List.of(testScene, scene2)); + + // Act + List result = sceneService.getAllScenes(); + + // Assert + assertEquals(2, result.size()); + verify(sceneRepository, times(1)).findAll(); + } + + @Test + void testGetScenesByChapterId() { + // Arrange + when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(testScene, scene2)); + + // Act + List result = sceneService.getScenesByChapterId("chapter-1"); + + // Assert + assertEquals(2, result.size()); + verify(sceneRepository, times(1)).findByChapterId("chapter-1"); + } + + @Test + void testUpdateScene_Success() { + // Arrange + Scene updatedScene = Scene.builder() + .name("Updated Scene") + .description("Updated Description") + .order(2) + .location("Tavern") + .timing("Evening") + .atmosphere("Cozy") + .playerNarration("Narration") + .gmSecretNotes("Secret") + .choicesConsequences("Choices") + .combatDifficulty("Medium") + .enemies("Goblins") + .relatedPageIds(List.of("page-1")) + .illustrationImageIds(List.of("image-1")) + .branches(List.of()) + .build(); + when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(testScene)); + when(sceneRepository.save(any(Scene.class))).thenReturn(testScene); + + // Act + Scene result = sceneService.updateScene("scene-1", updatedScene); + + // Assert + assertNotNull(result); + verify(sceneRepository, times(1)).findById("scene-1"); + verify(sceneRepository, times(1)).save(any(Scene.class)); + } + + @Test + void testUpdateScene_NotFound() { + // Arrange + Scene updatedScene = Scene.builder() + .name("Updated Scene") + .build(); + when(sceneRepository.findById("invalid-id")).thenReturn(Optional.empty()); + + // Act & Assert + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> sceneService.updateScene("invalid-id", updatedScene) + ); + assertEquals("Scene non trouvée avec l'ID: invalid-id", exception.getMessage()); + verify(sceneRepository, times(1)).findById("invalid-id"); + verify(sceneRepository, never()).save(any()); + } + + @Test + void testUpdateScene_WithValidBranches() { + // Arrange + SceneBranch branch = SceneBranch.builder() + .targetSceneId("scene-2") + .label("Go to scene 2") + .build(); + Scene updatedScene = Scene.builder() + .name("Updated Scene") + .branches(List.of(branch)) + .chapterId("chapter-1") + .build(); + when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(testScene)); + when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(testScene, scene2, scene3)); + when(sceneRepository.save(any(Scene.class))).thenReturn(testScene); + + // Act + Scene result = sceneService.updateScene("scene-1", updatedScene); + + // Assert + assertNotNull(result); + verify(sceneRepository, times(1)).findById("scene-1"); + verify(sceneRepository, times(1)).save(any(Scene.class)); + } + + @Test + void testUpdateScene_WithBranchToSelf() { + // Arrange + SceneBranch branch = SceneBranch.builder() + .targetSceneId("scene-1") + .label("Self-reference") + .build(); + Scene updatedScene = Scene.builder() + .name("Updated Scene") + .branches(List.of(branch)) + .chapterId("chapter-1") + .build(); + when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(testScene)); + when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(testScene, scene2)); + + // Act & Assert + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> sceneService.updateScene("scene-1", updatedScene) + ); + assertEquals("Une scène ne peut pas se brancher sur elle-même", exception.getMessage()); + verify(sceneRepository, times(1)).findById("scene-1"); + verify(sceneRepository, never()).save(any()); + } + + @Test + void testUpdateScene_WithBranchToDifferentChapter() { + // Arrange + SceneBranch branch = SceneBranch.builder() + .targetSceneId("scene-other-chapter") + .label("Go to other chapter") + .build(); + Scene updatedScene = Scene.builder() + .name("Updated Scene") + .branches(List.of(branch)) + .chapterId("chapter-1") + .build(); + when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(testScene)); + when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(testScene, scene2)); + + // Act & Assert + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> sceneService.updateScene("scene-1", updatedScene) + ); + assertTrue(exception.getMessage().contains("n'appartient pas au même chapitre")); + verify(sceneRepository, times(1)).findById("scene-1"); + verify(sceneRepository, never()).save(any()); + } + + @Test + void testUpdateScene_WithBranchNullTarget() { + // Arrange + SceneBranch branch = SceneBranch.builder() + .targetSceneId(null) + .label("Null target") + .build(); + Scene updatedScene = Scene.builder() + .name("Updated Scene") + .branches(List.of(branch)) + .chapterId("chapter-1") + .build(); + when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(testScene)); + + // Act & Assert + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> sceneService.updateScene("scene-1", updatedScene) + ); + assertEquals("Une branche doit avoir une scène de destination", exception.getMessage()); + verify(sceneRepository, times(1)).findById("scene-1"); + verify(sceneRepository, never()).save(any()); + } + + @Test + void testUpdateScene_WithBranchBlankTarget() { + // Arrange + SceneBranch branch = SceneBranch.builder() + .targetSceneId(" ") + .label("Blank target") + .build(); + Scene updatedScene = Scene.builder() + .name("Updated Scene") + .branches(List.of(branch)) + .chapterId("chapter-1") + .build(); + when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(testScene)); + + // Act & Assert + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> sceneService.updateScene("scene-1", updatedScene) + ); + assertEquals("Une branche doit avoir une scène de destination", exception.getMessage()); + verify(sceneRepository, times(1)).findById("scene-1"); + verify(sceneRepository, never()).save(any()); + } + + @Test + void testUpdateScene_WithEmptyBranches() { + // Arrange + Scene updatedScene = Scene.builder() + .name("Updated Scene") + .branches(List.of()) + .chapterId("chapter-1") + .build(); + when(sceneRepository.findById("scene-1")).thenReturn(Optional.of(testScene)); + when(sceneRepository.save(any(Scene.class))).thenReturn(testScene); + + // Act + Scene result = sceneService.updateScene("scene-1", updatedScene); + + // Assert + assertNotNull(result); + verify(sceneRepository, times(1)).findById("scene-1"); + verify(sceneRepository, times(1)).save(any(Scene.class)); + } + + @Test + void testDeleteScene() { + // Arrange + doNothing().when(sceneRepository).deleteById("scene-1"); + + // Act + sceneService.deleteScene("scene-1"); + + // Assert + verify(sceneRepository, times(1)).deleteById("scene-1"); + } + + @Test + void testSceneExists_True() { + // Arrange + when(sceneRepository.existsById("scene-1")).thenReturn(true); + + // Act + boolean result = sceneService.sceneExists("scene-1"); + + // Assert + assertTrue(result); + verify(sceneRepository, times(1)).existsById("scene-1"); + } + + @Test + void testSceneExists_False() { + // Arrange + when(sceneRepository.existsById("invalid-id")).thenReturn(false); + + // Act + boolean result = sceneService.sceneExists("invalid-id"); + + // Assert + assertFalse(result); + verify(sceneRepository, times(1)).existsById("invalid-id"); + } +} diff --git a/docs/plan.md b/docs/plan.md index 387a8e2..415492e 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -513,6 +513,66 @@ L'IA ne reçoit **pas** les binaires — juste un signal de présence (`illustra - URL publique d'une image : `/api/images/{id}/content` (proxy Java — évite d'exposer MinIO directement). - Validation MIME côté `ImageService` : `jpeg/png/webp/gif` uniquement, max 10 Mo. +## Feature "Branches narratives" ✅ (21 avril 2026, session 7) + +> ✅ **Graphe narratif intra-chapitre livré en 6 étapes.** Une scène peut maintenant pointer vers plusieurs autres scènes du même chapitre selon l'action des joueurs (logique "livre dont vous êtes le héros"). Vue graphique SVG custom (organigramme) accessible depuis `chapter-view`. + +### Cadrage produit +- **Scope** : branches intra-chapitre uniquement (cross-chapitre = scène "finale" qui mène au chapitre suivant, logique existante). +- **Champ `order` conservé (option A)** : la scène `order=1` devient le point d'entrée du graphe. Possibilité future de migrer vers Option B (`isEntryPoint: boolean`) sans effort majeur. +- **Formulaire avant graphe** : édition structurée des branches dans `scene-edit`, visualisation ensuite. + +### Étape 1 — Domain (DDD) ✅ +- **Nouveau** : `SceneBranch` (Value Object, `@Value @Builder @Jacksonized`) — 3 champs : `label`, `targetSceneId`, `condition`. +- **Scene.java** : nouveau champ `List branches` + méthodes métier `addBranch()` (garde anti-auto-référence) / `removeBranchTo()`. + +### Étape 2 — Persistance ✅ +- **Nouveau** : `SceneBranchListJsonConverter` (pattern homogène avec `StringListJsonConverter`, TEXT + JSON via Jackson). +- **SceneJpaEntity** : colonne `branches TEXT` avec `@Convert`. Colonne auto-créée par Hibernate `ddl-auto=update`. +- **PostgresSceneRepository** : mapping `branches` dans les deux sens avec copies défensives. + +### Étape 3 — API ✅ +- **Nouveau** : `SceneBranchDTO` (mutable, pour wire Jackson). +- **SceneDTO** : champ `branches: List`. +- **SceneMapper** : helpers `toBranchDTOs()` / `toBranchDomain()` branchés sur `toDTO()` / `toDomain()`. +- **Controller inchangé** : `PUT /api/scenes/{id}` accepte déjà les branches via `SceneDTO`. + +### Étape 4 — Service (validation métier) ✅ +- **SceneService.updateScene()** propage `branches` + appelle `validateBranches()`. +- Invariants vérifiés : targetSceneId non vide, pas d'auto-référence, appartenance au même chapitre. Chargement une seule fois des IDs du chapitre (évite N+1). + +### Étape 5 — Frontend (édition) ✅ +- **campaign.model.ts** : interface `SceneBranch` + champs `branches?` sur `Scene` / `SceneCreate`. +- **scene-edit** : nouvelle section expandable "🌿 Branches narratives". Chaque branche = carte avec libellé + `` n'était pas appliqué car les `