From c1e3a20e5a2b0b37525d79ed2580bd2cc747b009 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Fri, 26 Jun 2026 09:40:01 +0200 Subject: [PATCH] =?UTF-8?q?Ajout=20de=20tests=20unitaires=20pour=20am?= =?UTF-8?q?=C3=A9liorer=20la=20couverture=20globale=20des=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CampaignAdaptServiceTest.java | 69 ++++ .../CampaignBriefBuilderTest.java | 170 ++++++++ .../CampaignReferencedFlagsServiceTest.java | 107 +++++ .../ChapterStatusEnricherTest.java | 204 ++++++++++ .../campaigncontext/CharacterServiceTest.java | 183 +++++++++ .../ItemCatalogServiceTest.java | 234 +++++++++++ .../campaigncontext/NotebookServiceTest.java | 371 ++++++++++++++++++ .../RandomTableServiceTest.java | 268 +++++++++++++ .../ContentExportImportRoundTripTest.java | 246 ++++++++++++ .../pdf/PdfExportServiceRichTest.java | 166 ++++++++ 10 files changed, 2018 insertions(+) create mode 100644 core/src/test/java/com/loremind/application/campaigncontext/CampaignAdaptServiceTest.java create mode 100644 core/src/test/java/com/loremind/application/campaigncontext/CampaignBriefBuilderTest.java create mode 100644 core/src/test/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsServiceTest.java create mode 100644 core/src/test/java/com/loremind/application/campaigncontext/ChapterStatusEnricherTest.java create mode 100644 core/src/test/java/com/loremind/application/campaigncontext/CharacterServiceTest.java create mode 100644 core/src/test/java/com/loremind/application/campaigncontext/ItemCatalogServiceTest.java create mode 100644 core/src/test/java/com/loremind/application/campaigncontext/NotebookServiceTest.java create mode 100644 core/src/test/java/com/loremind/application/campaigncontext/RandomTableServiceTest.java create mode 100644 core/src/test/java/com/loremind/infrastructure/transfer/ContentExportImportRoundTripTest.java create mode 100644 core/src/test/java/com/loremind/infrastructure/transfer/pdf/PdfExportServiceRichTest.java diff --git a/core/src/test/java/com/loremind/application/campaigncontext/CampaignAdaptServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/CampaignAdaptServiceTest.java new file mode 100644 index 0000000..8c27fd3 --- /dev/null +++ b/core/src/test/java/com/loremind/application/campaigncontext/CampaignAdaptServiceTest.java @@ -0,0 +1,69 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +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.Optional; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour CampaignAdaptService. + * Mocks des ports (campagne, brief builder, advisor PDF du Brain). + * Vérifie la délégation streamée et l'échec quand la campagne est introuvable. + */ +@ExtendWith(MockitoExtension.class) +public class CampaignAdaptServiceTest { + + @Mock + private CampaignRepository campaignRepository; + @Mock + private CampaignBriefBuilder briefBuilder; + @Mock + private CampaignPdfAdvisor advisor; + + @InjectMocks + private CampaignAdaptService service; + + @Test + void testAdviseStreaming_DelegatesWithBrief() { + Campaign campaign = Campaign.builder().id("camp-1").name("Camp").build(); + when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign)); + when(briefBuilder.build(campaign)).thenReturn("BRIEF"); + + byte[] pdf = {1, 2, 3}; + Consumer onToken = t -> {}; + Runnable onComplete = () -> {}; + Consumer onError = e -> {}; + + service.adviseStreaming("camp-1", pdf, "doc.pdf", "[]", onToken, onComplete, onError); + + // Le brief construit doit être relayé tel quel à l'advisor, avec les mêmes callbacks. + verify(advisor).adviseStreaming( + eq(pdf), eq("doc.pdf"), eq("BRIEF"), eq("[]"), + eq(onToken), eq(onComplete), eq(onError)); + } + + @Test + void testAdviseStreaming_CampaignNotFound_Throws() { + when(campaignRepository.findById("missing")).thenReturn(Optional.empty()); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> service.adviseStreaming("missing", new byte[]{1}, "doc.pdf", "[]", + t -> {}, () -> {}, e -> {})); + assertEquals("Campagne introuvable : missing", ex.getMessage()); + + verify(briefBuilder, never()).build(any()); + verifyNoInteractions(advisor); + } +} diff --git a/core/src/test/java/com/loremind/application/campaigncontext/CampaignBriefBuilderTest.java b/core/src/test/java/com/loremind/application/campaigncontext/CampaignBriefBuilderTest.java new file mode 100644 index 0000000..23c2b5e --- /dev/null +++ b/core/src/test/java/com/loremind/application/campaigncontext/CampaignBriefBuilderTest.java @@ -0,0 +1,170 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.application.generationcontext.CampaignStructuralContextBuilder; +import com.loremind.application.generationcontext.LoreStructuralContextBuilder; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.generationcontext.CampaignStructuralContext; +import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary; +import com.loremind.domain.generationcontext.LoreStructuralContext; +import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary; +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.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour CampaignBriefBuilder. + * Mocks des deux builders de contexte structurel (campagne + lore). + * Vérifie l'assemblage du markdown : titres, vocabulaire HUB/LINEAR, PNJ, lore conditionnel. + */ +@ExtendWith(MockitoExtension.class) +public class CampaignBriefBuilderTest { + + @Mock + private CampaignStructuralContextBuilder campaignContextBuilder; + @Mock + private LoreStructuralContextBuilder loreContextBuilder; + + @InjectMocks + private CampaignBriefBuilder builder; + + private static CampaignStructuralContext emptyCtx(String name, String description) { + return new CampaignStructuralContext(name, description, List.of(), List.of(), List.of()); + } + + @Test + void testBuild_EmptyCampaign_HeaderAndNoArcsPlaceholder() { + Campaign campaign = Campaign.builder().id("camp-1").build(); + when(campaignContextBuilder.build("camp-1")).thenReturn(emptyCtx("Ma Campagne", "Un synopsis")); + + String result = builder.build(campaign); + + assertTrue(result.contains("# Campagne : Ma Campagne")); + assertTrue(result.contains("Un synopsis")); + assertTrue(result.contains("## Structure (arcs → chapitres → scènes)")); + assertTrue(result.contains("_(aucun arc pour le moment)_")); + // Pas de PNJ ni de lore. + assertFalse(result.contains("## PNJ existants")); + assertFalse(result.contains("## Univers")); + } + + @Test + void testBuild_BlankDescriptionOmitted() { + Campaign campaign = Campaign.builder().id("camp-1").build(); + when(campaignContextBuilder.build("camp-1")).thenReturn(emptyCtx("Camp", " ")); + + String result = builder.build(campaign); + + assertTrue(result.contains("# Campagne : Camp")); + // La description blanche ne doit pas générer de ligne propre. + assertFalse(result.contains(" \n")); + } + + @Test + void testBuild_HubArcUsesQuestVocabulary() { + Campaign campaign = Campaign.builder().id("camp-1").build(); + SceneSummary scene = new SceneSummary("Scène A", "desc scène", 0, List.of(), List.of()); + ChapterSummary chapter = new ChapterSummary("Quête 1", "desc quête", 0, List.of(scene)); + ArcSummary hubArc = new ArcSummary("Arc Hub", "desc arc", true, 0, List.of(chapter)); + CampaignStructuralContext ctx = new CampaignStructuralContext( + "Camp", null, List.of(hubArc), List.of(), List.of()); + when(campaignContextBuilder.build("camp-1")).thenReturn(ctx); + + String result = builder.build(campaign); + + assertTrue(result.contains("### Arc HUB (à quêtes) : Arc Hub — desc arc")); + assertTrue(result.contains("- Quête : Quête 1 — desc quête")); + assertTrue(result.contains(" - Scène : Scène A — desc scène")); + } + + @Test + void testBuild_LinearArcUsesChapterVocabulary() { + Campaign campaign = Campaign.builder().id("camp-1").build(); + ChapterSummary chapter = new ChapterSummary("Chapitre 1", null, 0, List.of()); + ArcSummary linearArc = new ArcSummary("Arc Linéaire", null, false, 0, List.of(chapter)); + CampaignStructuralContext ctx = new CampaignStructuralContext( + "Camp", null, List.of(linearArc), List.of(), List.of()); + when(campaignContextBuilder.build("camp-1")).thenReturn(ctx); + + String result = builder.build(campaign); + + assertTrue(result.contains("### Arc : Arc Linéaire")); + assertTrue(result.contains("- Chapitre : Chapitre 1")); + // La légende mentionne toujours « HUB » ; ce qui compte est que l'arc LINEAR + // n'emploie PAS le vocabulaire HUB (en-tête « ### Arc HUB » ni « Quête »). + assertFalse(result.contains("### Arc HUB")); + assertFalse(result.contains("- Quête :")); + } + + @Test + void testBuild_IncludesNpcsWhenPresent() { + Campaign campaign = Campaign.builder().id("camp-1").build(); + CampaignStructuralContext ctx = new CampaignStructuralContext( + "Camp", null, List.of(), + List.of(), + List.of(new NpcSummary("Gandalf", "magicien gris"), + new NpcSummary("Sauron", null))); + when(campaignContextBuilder.build("camp-1")).thenReturn(ctx); + + String result = builder.build(campaign); + + assertTrue(result.contains("## PNJ existants")); + assertTrue(result.contains("- Gandalf : magicien gris")); + assertTrue(result.contains("- Sauron")); + } + + @Test + void testBuild_AppendsLoreWhenLinked() { + Campaign campaign = Campaign.builder().id("camp-1").loreId("lore-1").build(); + when(campaignContextBuilder.build("camp-1")).thenReturn(emptyCtx("Camp", null)); + + Map> folders = new LinkedHashMap<>(); + folders.put("Lieux", List.of( + new PageSummary("La Comté", "tpl", Map.of(), List.of(), List.of()))); + LoreStructuralContext lore = new LoreStructuralContext( + "Terre du Milieu", "Un vaste monde", folders, List.of()); + when(loreContextBuilder.buildOptional("lore-1")).thenReturn(Optional.of(lore)); + + String result = builder.build(campaign); + + assertTrue(result.contains("## Univers (Lore) : Terre du Milieu")); + assertTrue(result.contains("Un vaste monde")); + assertTrue(result.contains("### Lieux")); + assertTrue(result.contains("- La Comté")); + } + + @Test + void testBuild_NotLinkedToLore_SkipsLoreBuilder() { + Campaign campaign = Campaign.builder().id("camp-1").build(); // loreId null + when(campaignContextBuilder.build("camp-1")).thenReturn(emptyCtx("Camp", null)); + + String result = builder.build(campaign); + + assertFalse(result.contains("## Univers")); + verify(loreContextBuilder, never()).buildOptional(anyString()); + } + + @Test + void testBuild_LinkedButLoreAbsent_NoLoreSection() { + Campaign campaign = Campaign.builder().id("camp-1").loreId("lore-1").build(); + when(campaignContextBuilder.build("camp-1")).thenReturn(emptyCtx("Camp", null)); + when(loreContextBuilder.buildOptional("lore-1")).thenReturn(Optional.empty()); + + String result = builder.build(campaign); + + assertFalse(result.contains("## Univers")); + } +} diff --git a/core/src/test/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsServiceTest.java new file mode 100644 index 0000000..ec05d6d --- /dev/null +++ b/core/src/test/java/com/loremind/application/campaigncontext/CampaignReferencedFlagsServiceTest.java @@ -0,0 +1,107 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.domain.campaigncontext.ports.ArcRepository; +import com.loremind.domain.campaigncontext.ports.ChapterRepository; +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 static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour CampaignReferencedFlagsService. + * Mocks des ports ArcRepository / ChapterRepository. + * Vérifie l'énumération dédupliquée + triée des FlagSet référencés par les chapitres. + */ +@ExtendWith(MockitoExtension.class) +public class CampaignReferencedFlagsServiceTest { + + @Mock + private ArcRepository arcRepository; + @Mock + private ChapterRepository chapterRepository; + + @InjectMocks + private CampaignReferencedFlagsService service; + + private static Chapter chapterWithPrereqs(String id, String arcId, Prerequisite... prereqs) { + return Chapter.builder() + .id(id) + .arcId(arcId) + .name("C-" + id) + .prerequisites(List.of(prereqs)) + .build(); + } + + @Test + void testListForCampaign_DeduplicatedAndSorted() { + // Arrange : deux arcs, plusieurs chapitres, flags dont certains en doublon. + Arc arc1 = Arc.builder().id("arc-1").campaignId("camp-1").build(); + Arc arc2 = Arc.builder().id("arc-2").campaignId("camp-1").build(); + when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of(arc1, arc2)); + + Chapter c1 = chapterWithPrereqs("c1", "arc-1", + new Prerequisite.FlagSet("zeta"), + new Prerequisite.FlagSet("alpha")); + Chapter c2 = chapterWithPrereqs("c2", "arc-2", + new Prerequisite.FlagSet("alpha"), // doublon -> dédupliqué + new Prerequisite.FlagSet("beta")); + when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(c1)); + when(chapterRepository.findByArcId("arc-2")).thenReturn(List.of(c2)); + + // Act + List result = service.listForCampaign("camp-1"); + + // Assert : tri alphabétique + déduplication. + assertEquals(List.of("alpha", "beta", "zeta"), result); + } + + @Test + void testListForCampaign_IgnoresNonFlagSetAndBlankNames() { + Arc arc = Arc.builder().id("arc-1").campaignId("camp-1").build(); + when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of(arc)); + + Chapter chapter = chapterWithPrereqs("c1", "arc-1", + new Prerequisite.QuestCompleted("quest-x"), // pas un FlagSet -> ignoré + new Prerequisite.SessionReached(3), // pas un FlagSet -> ignoré + new Prerequisite.FlagSet(""), // blanc -> ignoré + new Prerequisite.FlagSet(" "), // blanc -> ignoré + new Prerequisite.FlagSet("real")); + when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(chapter)); + + List result = service.listForCampaign("camp-1"); + + assertEquals(List.of("real"), result); + } + + @Test + void testListForCampaign_NullPrerequisitesSkipped() { + Arc arc = Arc.builder().id("arc-1").campaignId("camp-1").build(); + when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of(arc)); + + Chapter chapter = Chapter.builder().id("c1").arcId("arc-1").name("C").prerequisites(null).build(); + when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(chapter)); + + List result = service.listForCampaign("camp-1"); + + assertTrue(result.isEmpty()); + } + + @Test + void testListForCampaign_NoArcs() { + when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of()); + + List result = service.listForCampaign("camp-1"); + + assertTrue(result.isEmpty()); + verify(chapterRepository, never()).findByArcId(anyString()); + } +} diff --git a/core/src/test/java/com/loremind/application/campaigncontext/ChapterStatusEnricherTest.java b/core/src/test/java/com/loremind/application/campaigncontext/ChapterStatusEnricherTest.java new file mode 100644 index 0000000..4fcc2f0 --- /dev/null +++ b/core/src/test/java/com/loremind/application/campaigncontext/ChapterStatusEnricherTest.java @@ -0,0 +1,204 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Chapter; +import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.domain.campaigncontext.ProgressionStatus; +import com.loremind.domain.campaigncontext.QuestStatus; +import com.loremind.domain.playcontext.Playthrough; +import com.loremind.domain.playcontext.QuestProgression; +import com.loremind.domain.playcontext.Session; +import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository; +import com.loremind.domain.playcontext.ports.PlaythroughRepository; +import com.loremind.domain.playcontext.ports.QuestProgressionRepository; +import com.loremind.domain.playcontext.ports.SessionRepository; +import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO; +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.Map; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour ChapterStatusEnricher. + * Mocks des ports Play Context (playthrough, progression, flags, sessions). + * Vérifie la construction du snapshot d'évaluation + le calcul du statut effectif. + */ +@ExtendWith(MockitoExtension.class) +public class ChapterStatusEnricherTest { + + @Mock + private PlaythroughRepository playthroughRepository; + @Mock + private QuestProgressionRepository progressionRepository; + @Mock + private PlaythroughFlagRepository flagRepository; + @Mock + private SessionRepository sessionRepository; + + @InjectMocks + private ChapterStatusEnricher enricher; + + // --- buildSnapshot --- + + @Test + void testBuildSnapshot_NullPlaythroughId_ReturnsEmptyContext() { + // Aucune interaction avec les repos de données : court-circuit. + ChapterStatusEnricher.PlaythroughEvalSnapshot snap = enricher.buildSnapshot(null); + + assertNotNull(snap); + assertTrue(snap.progressionByChapterId().isEmpty()); + assertTrue(snap.ctx().completedQuestIds().isEmpty()); + assertEquals(0, snap.ctx().currentSessionCount()); + assertTrue(snap.ctx().campaignFlags().isEmpty()); + verify(flagRepository, never()).findByPlaythroughId(anyString()); + verify(sessionRepository, never()).findByPlaythroughId(anyString()); + } + + @Test + void testBuildSnapshot_UnknownPlaythrough_ReturnsEmptyContext() { + when(playthroughRepository.findById("pt-x")).thenReturn(Optional.empty()); + + ChapterStatusEnricher.PlaythroughEvalSnapshot snap = enricher.buildSnapshot("pt-x"); + + assertTrue(snap.progressionByChapterId().isEmpty()); + assertTrue(snap.ctx().completedQuestIds().isEmpty()); + verify(flagRepository, never()).findByPlaythroughId(anyString()); + } + + @Test + void testBuildSnapshot_PopulatesContextAndProgressionMap() { + when(playthroughRepository.findById("pt-1")) + .thenReturn(Optional.of(Playthrough.builder().id("pt-1").build())); + when(flagRepository.findByPlaythroughId("pt-1")).thenReturn(Map.of("door_open", true)); + when(progressionRepository.findCompletedChapterIdsByPlaythroughId("pt-1")) + .thenReturn(Set.of("chap-done")); + when(sessionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of( + Session.builder().id("s1").build(), + Session.builder().id("s2").build())); + when(progressionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of( + QuestProgression.builder().chapterId("chap-1").status(ProgressionStatus.IN_PROGRESS).build(), + QuestProgression.builder().chapterId("chap-2").status(ProgressionStatus.COMPLETED).build())); + + ChapterStatusEnricher.PlaythroughEvalSnapshot snap = enricher.buildSnapshot("pt-1"); + + assertEquals(Set.of("chap-done"), snap.ctx().completedQuestIds()); + assertEquals(2, snap.ctx().currentSessionCount()); + assertEquals(Map.of("door_open", true), snap.ctx().campaignFlags()); + assertEquals(ProgressionStatus.IN_PROGRESS, snap.progressionByChapterId().get("chap-1")); + assertEquals(ProgressionStatus.COMPLETED, snap.progressionByChapterId().get("chap-2")); + } + + // --- computeFor --- + + @Test + void testComputeFor_NotStartedWithNoPrereqs_Available() { + when(playthroughRepository.findById("pt-1")) + .thenReturn(Optional.of(Playthrough.builder().id("pt-1").build())); + when(flagRepository.findByPlaythroughId("pt-1")).thenReturn(Map.of()); + when(progressionRepository.findCompletedChapterIdsByPlaythroughId("pt-1")).thenReturn(Set.of()); + when(sessionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of()); + when(progressionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of()); + + Chapter chapter = Chapter.builder().id("chap-1").name("C").prerequisites(List.of()).build(); + + QuestStatus status = enricher.computeFor(chapter, "pt-1"); + + assertEquals(QuestStatus.AVAILABLE, status); + } + + @Test + void testComputeFor_NotStartedWithUnmetPrereq_Locked() { + when(playthroughRepository.findById("pt-1")) + .thenReturn(Optional.of(Playthrough.builder().id("pt-1").build())); + when(flagRepository.findByPlaythroughId("pt-1")).thenReturn(Map.of()); + when(progressionRepository.findCompletedChapterIdsByPlaythroughId("pt-1")).thenReturn(Set.of()); + when(sessionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of()); + when(progressionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of()); + + Chapter chapter = Chapter.builder().id("chap-1").name("C") + .prerequisites(List.of(new Prerequisite.QuestCompleted("other"))).build(); + + QuestStatus status = enricher.computeFor(chapter, "pt-1"); + + assertEquals(QuestStatus.LOCKED, status); + } + + @Test + void testComputeFor_CompletedProgression_Completed() { + when(playthroughRepository.findById("pt-1")) + .thenReturn(Optional.of(Playthrough.builder().id("pt-1").build())); + when(flagRepository.findByPlaythroughId("pt-1")).thenReturn(Map.of()); + when(progressionRepository.findCompletedChapterIdsByPlaythroughId("pt-1")).thenReturn(Set.of()); + when(sessionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of()); + when(progressionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of( + QuestProgression.builder().chapterId("chap-1").status(ProgressionStatus.COMPLETED).build())); + + Chapter chapter = Chapter.builder().id("chap-1").name("C").prerequisites(List.of()).build(); + + QuestStatus status = enricher.computeFor(chapter, "pt-1"); + + assertEquals(QuestStatus.COMPLETED, status); + } + + @Test + void testComputeFor_NoPlaythrough_FallsBackToAvailableWhenNoPrereqs() { + // playthroughId null -> snapshot vide -> NOT_STARTED par défaut. + Chapter chapter = Chapter.builder().id("chap-1").name("C").prerequisites(List.of()).build(); + + QuestStatus status = enricher.computeFor(chapter, null); + + assertEquals(QuestStatus.AVAILABLE, status); + } + + // --- enrich --- + + @Test + void testEnrich_NullOrEmptyDtos_NoSnapshotBuilt() { + enricher.enrich(null, List.of(), "pt-1"); + enricher.enrich(List.of(), List.of(), "pt-1"); + + // Aucun snapshot construit -> pas d'accès au repo playthrough. + verify(playthroughRepository, never()).findById(anyString()); + } + + @Test + void testEnrich_InjectsStatusesIntoMatchingDtos() { + when(playthroughRepository.findById("pt-1")) + .thenReturn(Optional.of(Playthrough.builder().id("pt-1").build())); + when(flagRepository.findByPlaythroughId("pt-1")).thenReturn(Map.of()); + when(progressionRepository.findCompletedChapterIdsByPlaythroughId("pt-1")).thenReturn(Set.of()); + when(sessionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of()); + when(progressionRepository.findByPlaythroughId("pt-1")).thenReturn(List.of( + QuestProgression.builder().chapterId("chap-1").status(ProgressionStatus.IN_PROGRESS).build())); + + Chapter c1 = Chapter.builder().id("chap-1").name("C1").prerequisites(List.of()).build(); + Chapter c2 = Chapter.builder().id("chap-2").name("C2").prerequisites(List.of()).build(); + + ChapterDTO d1 = new ChapterDTO(); + d1.setId("chap-1"); + ChapterDTO d2 = new ChapterDTO(); + d2.setId("chap-2"); + ChapterDTO orphan = new ChapterDTO(); + orphan.setId("chap-unknown"); // pas dans domain -> ignoré + + enricher.enrich(List.of(d1, d2, orphan), List.of(c1, c2), "pt-1"); + + // chap-1 : progression IN_PROGRESS -> effectif IN_PROGRESS. + assertEquals("IN_PROGRESS", d1.getProgressionStatus()); + assertEquals("IN_PROGRESS", d1.getEffectiveStatus()); + // chap-2 : pas de progression -> NOT_STARTED + sans prereq -> AVAILABLE. + assertEquals("NOT_STARTED", d2.getProgressionStatus()); + assertEquals("AVAILABLE", d2.getEffectiveStatus()); + // orphelin : non touché. + assertNull(orphan.getProgressionStatus()); + assertNull(orphan.getEffectiveStatus()); + } +} diff --git a/core/src/test/java/com/loremind/application/campaigncontext/CharacterServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/CharacterServiceTest.java new file mode 100644 index 0000000..a64d383 --- /dev/null +++ b/core/src/test/java/com/loremind/application/campaigncontext/CharacterServiceTest.java @@ -0,0 +1,183 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Character; +import com.loremind.domain.campaigncontext.ports.CharacterRepository; +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.Map; +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 CharacterService. + * Mock du port CharacterRepository. + */ +@ExtendWith(MockitoExtension.class) +public class CharacterServiceTest { + + @Mock + private CharacterRepository characterRepository; + + @InjectMocks + private CharacterService service; + + private static CharacterService.CharacterData data(Integer order, String playthroughId) { + return new CharacterService.CharacterData( + "Aragorn", "portrait-1", "header-1", + Map.of("Classe", "Rôdeur"), + Map.of("Galerie", List.of("img-1")), + Map.of("Stats", Map.of("FOR", "16")), + playthroughId, order); + } + + // --- createCharacter --- + + @Test + void testCreateCharacter_WithExplicitOrder() { + when(characterRepository.save(any(Character.class))).thenAnswer(inv -> inv.getArgument(0)); + + Character result = service.createCharacter(data(4, "pt-1")); + + assertEquals("Aragorn", result.getName()); + assertEquals(4, result.getOrder()); + assertEquals("Rôdeur", result.getValues().get("Classe")); + assertEquals("pt-1", result.getPlaythroughId()); + verify(characterRepository, never()).findByPlaythroughId(anyString()); + } + + @Test + void testCreateCharacter_ComputesNextOrderWhenNull() { + when(characterRepository.findByPlaythroughId("pt-1")).thenReturn(List.of( + Character.builder().order(1).build(), + Character.builder().order(4).build())); + when(characterRepository.save(any(Character.class))).thenAnswer(inv -> inv.getArgument(0)); + + Character result = service.createCharacter(data(null, "pt-1")); + + assertEquals(5, result.getOrder()); // max(1,4)+1 + } + + @Test + void testCreateCharacter_NextOrderZeroWhenNoExisting() { + when(characterRepository.findByPlaythroughId("pt-1")).thenReturn(List.of()); + when(characterRepository.save(any(Character.class))).thenAnswer(inv -> inv.getArgument(0)); + + Character result = service.createCharacter(data(null, "pt-1")); + + assertEquals(0, result.getOrder()); + } + + @Test + void testCreateCharacter_NullMapsBecomeEmpty() { + when(characterRepository.save(any(Character.class))).thenAnswer(inv -> inv.getArgument(0)); + CharacterService.CharacterData d = new CharacterService.CharacterData( + "Bob", null, null, null, null, null, "pt-1", 0); + + Character result = service.createCharacter(d); + + assertNotNull(result.getValues()); + assertTrue(result.getValues().isEmpty()); + assertNotNull(result.getImageValues()); + assertTrue(result.getImageValues().isEmpty()); + assertNotNull(result.getKeyValueValues()); + assertTrue(result.getKeyValueValues().isEmpty()); + } + + // --- read --- + + @Test + void testGetCharacterById_Found() { + Character c = Character.builder().id("c-1").name("Aragorn").build(); + when(characterRepository.findById("c-1")).thenReturn(Optional.of(c)); + + Optional result = service.getCharacterById("c-1"); + + assertTrue(result.isPresent()); + assertEquals("Aragorn", result.get().getName()); + } + + @Test + void testGetCharactersByPlaythroughId() { + when(characterRepository.findByPlaythroughId("pt-1")) + .thenReturn(List.of(Character.builder().id("c-1").build())); + + List result = service.getCharactersByPlaythroughId("pt-1"); + + assertEquals(1, result.size()); + } + + // --- updateCharacter --- + + @Test + void testUpdateCharacter_Success() { + Character existing = Character.builder().id("c-1").name("Old").order(2).playthroughId("pt-1").build(); + when(characterRepository.findById("c-1")).thenReturn(Optional.of(existing)); + when(characterRepository.save(any(Character.class))).thenAnswer(inv -> inv.getArgument(0)); + + Character result = service.updateCharacter("c-1", data(null, "pt-2")); + + assertEquals("Aragorn", result.getName()); + assertEquals("Rôdeur", result.getValues().get("Classe")); + // order null -> conserve l'ordre existant. + assertEquals(2, result.getOrder()); + // playthroughId immuable : inchangé malgré data.playthroughId="pt-2". + assertEquals("pt-1", result.getPlaythroughId()); + } + + @Test + void testUpdateCharacter_AppliesOrderWhenProvided() { + Character existing = Character.builder().id("c-1").name("Old").order(2).build(); + when(characterRepository.findById("c-1")).thenReturn(Optional.of(existing)); + when(characterRepository.save(any(Character.class))).thenAnswer(inv -> inv.getArgument(0)); + + Character result = service.updateCharacter("c-1", data(8, "pt-1")); + + assertEquals(8, result.getOrder()); + } + + @Test + void testUpdateCharacter_NotFound() { + when(characterRepository.findById("missing")).thenReturn(Optional.empty()); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> service.updateCharacter("missing", data(1, "pt-1"))); + assertEquals("Character non trouvé avec l'ID: missing", ex.getMessage()); + verify(characterRepository, never()).save(any()); + } + + // --- delete --- + + @Test + void testDeleteCharacter() { + service.deleteCharacter("c-1"); + verify(characterRepository).deleteById("c-1"); + } + + // --- searchCharacters --- + + @Test + void testSearchCharacters_BlankReturnsEmpty() { + assertTrue(service.searchCharacters(null).isEmpty()); + assertTrue(service.searchCharacters(" ").isEmpty()); + verify(characterRepository, never()).searchByName(anyString()); + } + + @Test + void testSearchCharacters_TrimsAndDelegates() { + when(characterRepository.searchByName("ara")).thenReturn(List.of(Character.builder().id("c-1").build())); + + List result = service.searchCharacters(" ara "); + + assertEquals(1, result.size()); + verify(characterRepository).searchByName("ara"); + } +} diff --git a/core/src/test/java/com/loremind/application/campaigncontext/ItemCatalogServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/ItemCatalogServiceTest.java new file mode 100644 index 0000000..0b8678b --- /dev/null +++ b/core/src/test/java/com/loremind/application/campaigncontext/ItemCatalogServiceTest.java @@ -0,0 +1,234 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.CatalogItem; +import com.loremind.domain.campaigncontext.ItemCatalog; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator; +import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository; +import com.loremind.domain.gamesystemcontext.GameSystem; +import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; +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.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour ItemCatalogService. + * Mocks des ports (repository, générateur IA, campagne, système de jeu). + */ +@ExtendWith(MockitoExtension.class) +public class ItemCatalogServiceTest { + + @Mock + private ItemCatalogRepository repository; + @Mock + private ItemCatalogGenerator generator; + @Mock + private CampaignRepository campaignRepository; + @Mock + private GameSystemRepository gameSystemRepository; + + @InjectMocks + private ItemCatalogService service; + + private static ItemCatalogService.CatalogData data(Integer order, String campaignId) { + return new ItemCatalogService.CatalogData( + "Échoppe", "desc", "icon", + List.of(CatalogItem.builder().name("Épée").price("50 po").build()), + campaignId, order); + } + + // --- createCatalog --- + + @Test + void testCreateCatalog_WithExplicitOrder() { + when(repository.save(any(ItemCatalog.class))).thenAnswer(inv -> inv.getArgument(0)); + + ItemCatalog result = service.createCatalog(data(3, "camp-1")); + + assertEquals("Échoppe", result.getName()); + assertEquals(3, result.getOrder()); + assertEquals(1, result.getItems().size()); + verify(repository, never()).findByCampaignId(anyString()); + } + + @Test + void testCreateCatalog_ComputesNextOrderWhenNull() { + when(repository.findByCampaignId("camp-1")).thenReturn(List.of( + ItemCatalog.builder().order(0).build(), + ItemCatalog.builder().order(3).build())); + when(repository.save(any(ItemCatalog.class))).thenAnswer(inv -> inv.getArgument(0)); + + ItemCatalog result = service.createCatalog(data(null, "camp-1")); + + assertEquals(4, result.getOrder()); // max(0,3)+1 + } + + @Test + void testCreateCatalog_NextOrderZeroWhenNoExisting() { + when(repository.findByCampaignId("camp-1")).thenReturn(List.of()); + when(repository.save(any(ItemCatalog.class))).thenAnswer(inv -> inv.getArgument(0)); + + ItemCatalog result = service.createCatalog(data(null, "camp-1")); + + assertEquals(0, result.getOrder()); + } + + @Test + void testCreateCatalog_NullItemsBecomeEmpty() { + when(repository.save(any(ItemCatalog.class))).thenAnswer(inv -> inv.getArgument(0)); + ItemCatalogService.CatalogData d = new ItemCatalogService.CatalogData( + "C", null, null, null, "camp-1", 0); + + ItemCatalog result = service.createCatalog(d); + + assertNotNull(result.getItems()); + assertTrue(result.getItems().isEmpty()); + } + + // --- read --- + + @Test + void testGetCatalogById_Found() { + ItemCatalog c = ItemCatalog.builder().id("c-1").name("C").build(); + when(repository.findById("c-1")).thenReturn(Optional.of(c)); + + Optional result = service.getCatalogById("c-1"); + + assertTrue(result.isPresent()); + assertEquals("C", result.get().getName()); + } + + @Test + void testGetCatalogsByCampaignId() { + when(repository.findByCampaignId("camp-1")).thenReturn(List.of(ItemCatalog.builder().id("c-1").build())); + + List result = service.getCatalogsByCampaignId("camp-1"); + + assertEquals(1, result.size()); + } + + // --- updateCatalog --- + + @Test + void testUpdateCatalog_Success() { + ItemCatalog existing = ItemCatalog.builder().id("c-1").name("Old").order(2).build(); + when(repository.findById("c-1")).thenReturn(Optional.of(existing)); + when(repository.save(any(ItemCatalog.class))).thenAnswer(inv -> inv.getArgument(0)); + + ItemCatalog result = service.updateCatalog("c-1", data(null, "camp-1")); + + assertEquals("Échoppe", result.getName()); + assertEquals(1, result.getItems().size()); + // order null -> conserve l'ordre existant. + assertEquals(2, result.getOrder()); + } + + @Test + void testUpdateCatalog_AppliesOrderWhenProvided() { + ItemCatalog existing = ItemCatalog.builder().id("c-1").name("Old").order(2).build(); + when(repository.findById("c-1")).thenReturn(Optional.of(existing)); + when(repository.save(any(ItemCatalog.class))).thenAnswer(inv -> inv.getArgument(0)); + + ItemCatalog result = service.updateCatalog("c-1", data(6, "camp-1")); + + assertEquals(6, result.getOrder()); + } + + @Test + void testUpdateCatalog_NotFound() { + when(repository.findById("missing")).thenReturn(Optional.empty()); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> service.updateCatalog("missing", data(1, "camp-1"))); + assertEquals("Catalogue d'objets introuvable: missing", ex.getMessage()); + verify(repository, never()).save(any()); + } + + // --- delete --- + + @Test + void testDeleteCatalog() { + service.deleteCatalog("c-1"); + verify(repository).deleteById("c-1"); + } + + // --- searchCatalogs --- + + @Test + void testSearchCatalogs_BlankReturnsEmpty() { + assertTrue(service.searchCatalogs(null).isEmpty()); + assertTrue(service.searchCatalogs(" ").isEmpty()); + verify(repository, never()).searchByName(anyString()); + } + + @Test + void testSearchCatalogs_TrimsAndDelegates() { + when(repository.searchByName("epee")).thenReturn(List.of(ItemCatalog.builder().id("c-1").build())); + + List result = service.searchCatalogs(" epee "); + + assertEquals(1, result.size()); + verify(repository).searchByName("epee"); + } + + // --- generateProposal --- + + @Test + void testGenerateProposal_CopiesItemsAndContext() { + CatalogItem item = CatalogItem.builder().name("Potion").build(); + when(generator.generate(eq("desc"), anyString())) + .thenReturn(new ItemCatalogGenerator.GeneratedCatalog("Nom IA", "Desc IA", List.of(item))); + when(campaignRepository.findById("camp-1")) + .thenReturn(Optional.of(Campaign.builder().id("camp-1").name("Camp").build())); + + ItemCatalog result = service.generateProposal("camp-1", "desc"); + + assertEquals("Nom IA", result.getName()); + assertEquals("camp-1", result.getCampaignId()); + assertEquals(1, result.getItems().size()); + } + + @Test + void testGenerateProposal_NullGeneratedItems() { + when(generator.generate(anyString(), anyString())) + .thenReturn(new ItemCatalogGenerator.GeneratedCatalog("N", "D", null)); + when(campaignRepository.findById("camp-1")).thenReturn(Optional.empty()); + + ItemCatalog result = service.generateProposal("camp-1", "desc"); + + assertNotNull(result.getItems()); + assertTrue(result.getItems().isEmpty()); + } + + @Test + void testGenerateProposal_BuildsContextWithGameSystem() { + when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(Campaign.builder() + .id("camp-1").name("Camp").description(" Aventure ").gameSystemId("gs-1").build())); + when(gameSystemRepository.findById("gs-1")) + .thenReturn(Optional.of(GameSystem.builder().id("gs-1").name("Pathfinder").build())); + when(generator.generate(anyString(), any())) + .thenReturn(new ItemCatalogGenerator.GeneratedCatalog("N", "D", List.of())); + + service.generateProposal("camp-1", "desc"); + + ArgumentCaptor ctxCaptor = ArgumentCaptor.forClass(String.class); + verify(generator).generate(eq("desc"), ctxCaptor.capture()); + String ctx = ctxCaptor.getValue(); + assertTrue(ctx.contains("Camp")); + assertTrue(ctx.contains("Aventure")); + assertTrue(ctx.contains("Pathfinder")); + } +} diff --git a/core/src/test/java/com/loremind/application/campaigncontext/NotebookServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/NotebookServiceTest.java new file mode 100644 index 0000000..227a677 --- /dev/null +++ b/core/src/test/java/com/loremind/application/campaigncontext/NotebookServiceTest.java @@ -0,0 +1,371 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.Notebook; +import com.loremind.domain.campaigncontext.NotebookMessage; +import com.loremind.domain.campaigncontext.NotebookSource; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.NotebookIndexer; +import com.loremind.domain.campaigncontext.ports.NotebookRepository; +import com.loremind.domain.gamesystemcontext.GameSystem; +import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; +import com.loremind.domain.shared.template.FieldType; +import com.loremind.domain.shared.template.TemplateField; +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.time.LocalDateTime; +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 NotebookService. + * Mocks des ports (repository, indexer Brain, campagne, brief builder, système). + */ +@ExtendWith(MockitoExtension.class) +public class NotebookServiceTest { + + @Mock + private NotebookRepository repository; + @Mock + private NotebookIndexer indexer; + @Mock + private CampaignRepository campaignRepository; + @Mock + private CampaignBriefBuilder briefBuilder; + @Mock + private GameSystemRepository gameSystemRepository; + + @InjectMocks + private NotebookService service; + + // --- createNotebook --- + + @Test + void testCreateNotebook_UsesProvidedName() { + when(repository.save(any(Notebook.class))).thenAnswer(inv -> inv.getArgument(0)); + + Notebook result = service.createNotebook("camp-1", " Mon atelier "); + + assertEquals("Mon atelier", result.getName()); // trim + assertEquals("camp-1", result.getCampaignId()); + } + + @Test + void testCreateNotebook_DefaultNameWhenBlank() { + when(repository.save(any(Notebook.class))).thenAnswer(inv -> inv.getArgument(0)); + + Notebook result = service.createNotebook("camp-1", " "); + + assertEquals("Nouvel atelier", result.getName()); + } + + @Test + void testCreateNotebook_DefaultNameWhenNull() { + when(repository.save(any(Notebook.class))).thenAnswer(inv -> inv.getArgument(0)); + + Notebook result = service.createNotebook("camp-1", null); + + assertEquals("Nouvel atelier", result.getName()); + } + + // --- renameNotebook --- + + @Test + void testRenameNotebook_Success() { + Notebook existing = Notebook.builder().id("nb-1").name("Old").build(); + when(repository.findById("nb-1")).thenReturn(Optional.of(existing)); + when(repository.save(any(Notebook.class))).thenAnswer(inv -> inv.getArgument(0)); + + Notebook result = service.renameNotebook("nb-1", " Nouveau "); + + assertEquals("Nouveau", result.getName()); + } + + @Test + void testRenameNotebook_BlankKeepsExistingName() { + Notebook existing = Notebook.builder().id("nb-1").name("Old").build(); + when(repository.findById("nb-1")).thenReturn(Optional.of(existing)); + when(repository.save(any(Notebook.class))).thenAnswer(inv -> inv.getArgument(0)); + + Notebook result = service.renameNotebook("nb-1", " "); + + assertEquals("Old", result.getName()); + } + + @Test + void testRenameNotebook_NotFound() { + when(repository.findById("missing")).thenReturn(Optional.empty()); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> service.renameNotebook("missing", "X")); + assertEquals("Notebook introuvable: missing", ex.getMessage()); + } + + // --- deleteNotebook --- + + @Test + void testDeleteNotebook_DeletesVectorsThenRow() { + when(repository.findSourcesByNotebookId("nb-1")).thenReturn(List.of( + NotebookSource.builder().id("src-1").build(), + NotebookSource.builder().id("src-2").build())); + + service.deleteNotebook("nb-1"); + + verify(indexer).delete("src-1"); + verify(indexer).delete("src-2"); + verify(repository).deleteById("nb-1"); + } + + // --- addSource --- + + @Test + void testAddSource_Success_SetsReadyAndCounters() { + when(repository.existsById("nb-1")).thenReturn(true); + when(repository.saveSource(any(NotebookSource.class))).thenAnswer(inv -> { + NotebookSource s = inv.getArgument(0); + if (s.getId() == null) s.setId("src-1"); + return s; + }); + when(indexer.index(eq("src-1"), any(), eq("doc.pdf"))) + .thenReturn(new NotebookIndexer.IndexResult(42, 10, 2)); + + NotebookSource result = service.addSource("nb-1", "doc.pdf", new byte[]{1, 2, 3}); + + assertEquals("READY", result.getStatus()); + assertEquals(42, result.getChunkCount()); + assertEquals(10, result.getPageCount()); + // saveSource appelé 2 fois : création INDEXING + mise à jour READY. + verify(repository, times(2)).saveSource(any(NotebookSource.class)); + } + + @Test + void testAddSource_DefaultFilenameWhenBlank() { + when(repository.existsById("nb-1")).thenReturn(true); + when(repository.saveSource(any(NotebookSource.class))).thenAnswer(inv -> { + NotebookSource s = inv.getArgument(0); + if (s.getId() == null) s.setId("src-1"); + return s; + }); + when(indexer.index(anyString(), any(), any())) + .thenReturn(new NotebookIndexer.IndexResult(1, 1, 0)); + + service.addSource("nb-1", " ", new byte[]{1}); + + ArgumentCaptor captor = ArgumentCaptor.forClass(NotebookSource.class); + verify(repository, atLeastOnce()).saveSource(captor.capture()); + assertEquals("source.pdf", captor.getAllValues().get(0).getFilename()); + } + + @Test + void testAddSource_NotebookNotFound() { + when(repository.existsById("missing")).thenReturn(false); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> service.addSource("missing", "doc.pdf", new byte[]{1})); + assertEquals("Notebook introuvable: missing", ex.getMessage()); + verify(repository, never()).saveSource(any()); + } + + @Test + void testAddSource_IndexingFailure_SetsFailedAndRethrows() { + when(repository.existsById("nb-1")).thenReturn(true); + when(repository.saveSource(any(NotebookSource.class))).thenAnswer(inv -> { + NotebookSource s = inv.getArgument(0); + if (s.getId() == null) s.setId("src-1"); + return s; + }); + when(indexer.index(anyString(), any(), any())) + .thenThrow(new RuntimeException("Brain KO")); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> service.addSource("nb-1", "doc.pdf", new byte[]{1})); + assertEquals("Brain KO", ex.getMessage()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(NotebookSource.class); + verify(repository, times(2)).saveSource(captor.capture()); + assertEquals("FAILED", captor.getAllValues().get(1).getStatus()); + } + + // --- deleteSource --- + + @Test + void testDeleteSource_DeletesVectorsThenRow() { + when(repository.findSourceById("src-1")) + .thenReturn(Optional.of(NotebookSource.builder().id("src-1").build())); + + service.deleteSource("src-1"); + + verify(indexer).delete("src-1"); + verify(repository).deleteSourceById("src-1"); + } + + @Test + void testDeleteSource_UnknownSource_StillDeletesRow() { + when(repository.findSourceById("src-x")).thenReturn(Optional.empty()); + + service.deleteSource("src-x"); + + verify(indexer, never()).delete(anyString()); + verify(repository).deleteSourceById("src-x"); + } + + // --- readySourceIds --- + + @Test + void testReadySourceIds_FiltersOnReadyStatus() { + when(repository.findSourcesByNotebookId("nb-1")).thenReturn(List.of( + NotebookSource.builder().id("a").status("READY").build(), + NotebookSource.builder().id("b").status("INDEXING").build(), + NotebookSource.builder().id("c").status("READY").build(), + NotebookSource.builder().id("d").status("FAILED").build())); + + List result = service.readySourceIds("nb-1"); + + assertEquals(List.of("a", "c"), result); + } + + // --- messages --- + + @Test + void testAddMessage_DelegatesToRepo() { + when(repository.saveMessage(any(NotebookMessage.class))).thenAnswer(inv -> inv.getArgument(0)); + + NotebookMessage result = service.addMessage("nb-1", "user", "salut"); + + assertEquals("nb-1", result.getNotebookId()); + assertEquals("user", result.getRole()); + assertEquals("salut", result.getContent()); + } + + @Test + void testClearChat_Archives() { + service.clearChat("nb-1"); + verify(repository).archiveMessagesByNotebookId("nb-1"); + } + + // --- buildArchiveContext --- + + @Test + void testBuildArchiveContext_EmptyKeysReturnsEmpty() { + assertEquals("", service.buildArchiveContext("nb-1", null)); + assertEquals("", service.buildArchiveContext("nb-1", List.of())); + verify(repository, never()).findArchivedMessagesByNotebookId(anyString()); + } + + @Test + void testBuildArchiveContext_NoMatchingKeysReturnsEmpty() { + LocalDateTime at = LocalDateTime.of(2026, 1, 1, 10, 0); + when(repository.findArchivedMessagesByNotebookId("nb-1")).thenReturn(List.of( + NotebookMessage.builder().role("user").content("hi").archivedAt(at).build())); + + String result = service.buildArchiveContext("nb-1", List.of("2099-12-31T00:00")); + + assertEquals("", result); + } + + @Test + void testBuildArchiveContext_FormatsSelectedArchive() { + LocalDateTime at = LocalDateTime.of(2026, 1, 1, 10, 0); + when(repository.findArchivedMessagesByNotebookId("nb-1")).thenReturn(List.of( + NotebookMessage.builder().role("user").content("Question ?").archivedAt(at).build(), + NotebookMessage.builder().role("assistant").content("Réponse.").archivedAt(at).build())); + + String result = service.buildArchiveContext("nb-1", List.of(at.toString())); + + assertTrue(result.contains("ANCIENNES CONVERSATIONS")); + assertTrue(result.contains("[Archive du " + at + "]")); + assertTrue(result.contains("MJ : Question ?")); + assertTrue(result.contains("IA : Réponse.")); + assertTrue(result.contains("FIN DES ANCIENNES CONVERSATIONS")); + } + + @Test + void testBuildArchiveContext_TruncatesFromStartWhenTooLong() { + LocalDateTime at = LocalDateTime.of(2026, 1, 1, 10, 0); + // Budget pour 1 seule archive = max(2000, 16000/1) = 16000 chars. Au-delà, + // la troncature se fait PAR LE DÉBUT : la fin (conclusion) doit survivre. + String head = "A".repeat(17000); + String tail = "CONCLUSION_UTILE"; + when(repository.findArchivedMessagesByNotebookId("nb-1")).thenReturn(List.of( + NotebookMessage.builder().role("assistant").content(head + tail).archivedAt(at).build())); + + String result = service.buildArchiveContext("nb-1", List.of(at.toString())); + + assertTrue(result.contains("[…début tronqué…]")); + assertTrue(result.contains(tail)); + } + + // --- buildContext (campagne) --- + + @Test + void testBuildContext_NullCampaignIdReturnsEmpty() { + assertEquals("", service.buildContext(null)); + verify(briefBuilder, never()).build(any()); + } + + @Test + void testBuildContext_UnknownCampaignReturnsEmpty() { + when(campaignRepository.findById("camp-x")).thenReturn(Optional.empty()); + + assertEquals("", service.buildContext("camp-x")); + verify(briefBuilder, never()).build(any()); + } + + @Test + void testBuildContext_BriefOnlyWhenNoGameSystem() { + Campaign campaign = Campaign.builder().id("camp-1").name("Camp").build(); + when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign)); + when(briefBuilder.build(campaign)).thenReturn("BRIEF"); + + String result = service.buildContext("camp-1"); + + assertEquals("BRIEF", result); + } + + @Test + void testBuildContext_AppendsNpcTextFields() { + Campaign campaign = Campaign.builder().id("camp-1").name("Camp").gameSystemId("gs-1").build(); + when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign)); + when(briefBuilder.build(campaign)).thenReturn("BRIEF"); + + GameSystem gs = GameSystem.builder() + .id("gs-1") + .npcTemplate(List.of( + new TemplateField("Histoire", FieldType.TEXT), + new TemplateField("Apparence", FieldType.TEXT), + new TemplateField("Portrait", FieldType.IMAGE))) // non-TEXT -> exclu + .build(); + when(gameSystemRepository.findById("gs-1")).thenReturn(Optional.of(gs)); + + String result = service.buildContext("camp-1"); + + assertTrue(result.startsWith("BRIEF\n\n")); + assertTrue(result.contains("FICHE PNJ")); + assertTrue(result.contains("Histoire")); + assertTrue(result.contains("Apparence")); + assertFalse(result.contains("Portrait")); + } + + @Test + void testBuildContext_NoNpcTemplateReturnsBriefOnly() { + Campaign campaign = Campaign.builder().id("camp-1").name("Camp").gameSystemId("gs-1").build(); + when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign)); + when(briefBuilder.build(campaign)).thenReturn("BRIEF"); + when(gameSystemRepository.findById("gs-1")) + .thenReturn(Optional.of(GameSystem.builder().id("gs-1").npcTemplate(null).build())); + + String result = service.buildContext("camp-1"); + + assertEquals("BRIEF", result); + } +} diff --git a/core/src/test/java/com/loremind/application/campaigncontext/RandomTableServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/RandomTableServiceTest.java new file mode 100644 index 0000000..74e0893 --- /dev/null +++ b/core/src/test/java/com/loremind/application/campaigncontext/RandomTableServiceTest.java @@ -0,0 +1,268 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.RandomTable; +import com.loremind.domain.campaigncontext.RandomTableEntry; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.RandomTableGenerator; +import com.loremind.domain.campaigncontext.ports.RandomTableRepository; +import com.loremind.domain.gamesystemcontext.GameSystem; +import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; +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.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour RandomTableService. + * Mocks des ports (repository, générateur IA, campagne, système de jeu). + */ +@ExtendWith(MockitoExtension.class) +public class RandomTableServiceTest { + + @Mock + private RandomTableRepository repository; + @Mock + private RandomTableGenerator generator; + @Mock + private CampaignRepository campaignRepository; + @Mock + private GameSystemRepository gameSystemRepository; + + @InjectMocks + private RandomTableService service; + + private static RandomTableService.TableData data(Integer order, String campaignId) { + return new RandomTableService.TableData( + "Rencontres", "desc", "1d20", "dice", + List.of(RandomTableEntry.builder().minRoll(1).maxRoll(10).label("Gobelins").build()), + campaignId, order); + } + + // --- createTable --- + + @Test + void testCreateTable_WithExplicitOrder() { + when(repository.save(any(RandomTable.class))).thenAnswer(inv -> inv.getArgument(0)); + + RandomTable result = service.createTable(data(5, "camp-1")); + + assertEquals(5, result.getOrder()); + assertEquals("Rencontres", result.getName()); + assertEquals(1, result.getEntries().size()); + // Pas de calcul d'ordre auto puisque order fourni. + verify(repository, never()).findByCampaignId(anyString()); + } + + @Test + void testCreateTable_ComputesNextOrderWhenNull() { + when(repository.findByCampaignId("camp-1")).thenReturn(List.of( + RandomTable.builder().order(2).build(), + RandomTable.builder().order(7).build())); + when(repository.save(any(RandomTable.class))).thenAnswer(inv -> inv.getArgument(0)); + + RandomTable result = service.createTable(data(null, "camp-1")); + + assertEquals(8, result.getOrder()); // max(2,7)+1 + } + + @Test + void testCreateTable_NextOrderZeroWhenNoExisting() { + when(repository.findByCampaignId("camp-1")).thenReturn(List.of()); + when(repository.save(any(RandomTable.class))).thenAnswer(inv -> inv.getArgument(0)); + + RandomTable result = service.createTable(data(null, "camp-1")); + + assertEquals(0, result.getOrder()); // -1 + 1 + } + + @Test + void testCreateTable_NullEntriesYieldsEmptyList() { + when(repository.save(any(RandomTable.class))).thenAnswer(inv -> inv.getArgument(0)); + RandomTableService.TableData d = new RandomTableService.TableData( + "T", null, "1d6", null, null, "camp-1", 0); + + RandomTable result = service.createTable(d); + + assertNotNull(result.getEntries()); + assertTrue(result.getEntries().isEmpty()); + } + + // --- read --- + + @Test + void testGetTableById_Found() { + RandomTable t = RandomTable.builder().id("t-1").name("T").build(); + when(repository.findById("t-1")).thenReturn(Optional.of(t)); + + Optional result = service.getTableById("t-1"); + + assertTrue(result.isPresent()); + assertEquals("T", result.get().getName()); + } + + @Test + void testGetTablesByCampaignId() { + when(repository.findByCampaignId("camp-1")).thenReturn(List.of(RandomTable.builder().id("t-1").build())); + + List result = service.getTablesByCampaignId("camp-1"); + + assertEquals(1, result.size()); + } + + // --- updateTable --- + + @Test + void testUpdateTable_Success() { + RandomTable existing = RandomTable.builder().id("t-1").name("Old").order(3).build(); + when(repository.findById("t-1")).thenReturn(Optional.of(existing)); + when(repository.save(any(RandomTable.class))).thenAnswer(inv -> inv.getArgument(0)); + + RandomTable result = service.updateTable("t-1", data(null, "camp-1")); + + assertEquals("Rencontres", result.getName()); + assertEquals("1d20", result.getDiceFormula()); + // order null dans data -> conserve l'ordre existant. + assertEquals(3, result.getOrder()); + } + + @Test + void testUpdateTable_AppliesOrderWhenProvided() { + RandomTable existing = RandomTable.builder().id("t-1").name("Old").order(3).build(); + when(repository.findById("t-1")).thenReturn(Optional.of(existing)); + when(repository.save(any(RandomTable.class))).thenAnswer(inv -> inv.getArgument(0)); + + RandomTable result = service.updateTable("t-1", data(9, "camp-1")); + + assertEquals(9, result.getOrder()); + } + + @Test + void testUpdateTable_NotFound() { + when(repository.findById("missing")).thenReturn(Optional.empty()); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> service.updateTable("missing", data(1, "camp-1"))); + assertEquals("Table aléatoire introuvable: missing", ex.getMessage()); + verify(repository, never()).save(any()); + } + + // --- delete --- + + @Test + void testDeleteTable() { + service.deleteTable("t-1"); + verify(repository).deleteById("t-1"); + } + + // --- reorder --- + + @Test + void testReorderTables_AssignsPositions() { + RandomTable a = RandomTable.builder().id("a").order(99).build(); + RandomTable b = RandomTable.builder().id("b").order(99).build(); + when(repository.findById("a")).thenReturn(Optional.of(a)); + when(repository.findById("b")).thenReturn(Optional.of(b)); + when(repository.save(any(RandomTable.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.reorderTables(List.of("a", "b")); + + assertEquals(0, a.getOrder()); + assertEquals(1, b.getOrder()); + verify(repository).save(a); + verify(repository).save(b); + } + + // --- searchTables --- + + @Test + void testSearchTables_BlankReturnsEmpty() { + assertTrue(service.searchTables(null).isEmpty()); + assertTrue(service.searchTables(" ").isEmpty()); + verify(repository, never()).searchByName(anyString()); + } + + @Test + void testSearchTables_TrimsAndDelegates() { + when(repository.searchByName("orc")).thenReturn(List.of(RandomTable.builder().id("t-1").build())); + + List result = service.searchTables(" orc "); + + assertEquals(1, result.size()); + verify(repository).searchByName("orc"); + } + + // --- generateProposal --- + + @Test + void testGenerateProposal_DefaultsFormulaAndCopiesEntries() { + RandomTableEntry e = RandomTableEntry.builder().minRoll(1).maxRoll(1).label("X").build(); + when(generator.generate(eq("desc"), eq("1d20"), anyString())) + .thenReturn(new RandomTableGenerator.GeneratedTable("Nom IA", "Desc IA", List.of(e))); + when(campaignRepository.findById("camp-1")) + .thenReturn(Optional.of(Campaign.builder().id("camp-1").name("Ma Campagne").build())); + + RandomTable result = service.generateProposal("camp-1", "desc", " "); + + assertEquals("Nom IA", result.getName()); + assertEquals("1d20", result.getDiceFormula()); // formule blanche -> défaut 1d20 + assertEquals("camp-1", result.getCampaignId()); + assertEquals(1, result.getEntries().size()); + } + + @Test + void testGenerateProposal_NullGeneratedEntries() { + when(generator.generate(anyString(), eq("2d6"), anyString())) + .thenReturn(new RandomTableGenerator.GeneratedTable("N", "D", null)); + when(campaignRepository.findById("camp-1")).thenReturn(Optional.empty()); + + RandomTable result = service.generateProposal("camp-1", "desc", "2d6"); + + assertNotNull(result.getEntries()); + assertTrue(result.getEntries().isEmpty()); + } + + @Test + void testGenerateProposal_BuildsContextWithGameSystem() { + when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(Campaign.builder() + .id("camp-1").name("Camp").description(" Une aventure ").gameSystemId("gs-1").build())); + when(gameSystemRepository.findById("gs-1")) + .thenReturn(Optional.of(GameSystem.builder().id("gs-1").name("D&D 5e").build())); + when(generator.generate(anyString(), anyString(), any())) + .thenReturn(new RandomTableGenerator.GeneratedTable("N", "D", List.of())); + + service.generateProposal("camp-1", "desc", "1d8"); + + ArgumentCaptor ctxCaptor = ArgumentCaptor.forClass(String.class); + verify(generator).generate(eq("desc"), eq("1d8"), ctxCaptor.capture()); + String ctx = ctxCaptor.getValue(); + assertTrue(ctx.contains("Camp")); + assertTrue(ctx.contains("Une aventure")); + assertTrue(ctx.contains("D&D 5e")); + } + + // --- improviseRoll --- + + @Test + void testImproviseRoll_DelegatesToGenerator() { + when(campaignRepository.findById("camp-1")).thenReturn(Optional.empty()); + when(generator.improvise(eq("Rencontres"), eq("Gobelins"), eq("detail"), anyString())) + .thenReturn("Un récit improvisé."); + + String result = service.improviseRoll("camp-1", "Rencontres", "Gobelins", "detail"); + + assertEquals("Un récit improvisé.", result); + verify(generator).improvise("Rencontres", "Gobelins", "detail", ""); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportImportRoundTripTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportImportRoundTripTest.java new file mode 100644 index 0000000..c7226c2 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportImportRoundTripTest.java @@ -0,0 +1,246 @@ +package com.loremind.infrastructure.transfer; + +import com.loremind.domain.campaigncontext.ArcType; +import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.domain.campaigncontext.SceneBranch; +import com.loremind.domain.files.ports.FileStorage; +import com.loremind.domain.images.ports.ImageStorage; +import com.loremind.infrastructure.persistence.entity.*; +import com.loremind.infrastructure.persistence.jpa.*; +import com.loremind.infrastructure.transfer.dto.ContentExport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +/** + * Aller-retour EXPORT → ZIP → IMPORT (mode FUSION) du contenu portable. + *

+ * On sème un graphe complet (système de jeu, lore/dossiers/template/page, campagne, + * arc, chapitres avec prérequis, scène avec branches/ennemis/battlemap, PNJ, ennemi, + * personnage, catalogue d'objets, table aléatoire, image + fichier), on l'exporte, + * on le réimporte, puis on vérifie : + *

    + *
  • le zip embarque {@code manifest.json}, {@code data.json} et les binaires + * d'images/fichiers RÉFÉRENCÉS ;
  • + *
  • l'import DOUBLE chaque type (l'export étant global) ;
  • + *
  • les clés d'images (uniques) sont RÉUTILISÉES, pas dupliquées ;
  • + *
  • les références sont remappées en 2e passe (l'arc importé pointe la page + * importée, pas l'originale) ;
  • + *
  • le {@code playthroughId} d'un personnage est remis à null à l'import.
  • + *
+ * Le stockage binaire (MinIO) est mocké : aucun appel réseau, et on contrôle ce que + * {@code download} renvoie pour les clés référencées. + */ +@SpringBootTest +@Transactional +class ContentExportImportRoundTripTest { + + @Autowired private ExportService exportService; + @Autowired private ImportService importService; + + @Autowired private GameSystemJpaRepository gameSystemRepo; + @Autowired private LoreJpaRepository loreRepo; + @Autowired private LoreNodeJpaRepository loreNodeRepo; + @Autowired private TemplateJpaRepository templateRepo; + @Autowired private PageJpaRepository pageRepo; + @Autowired private CampaignJpaRepository campaignRepo; + @Autowired private ArcJpaRepository arcRepo; + @Autowired private ChapterJpaRepository chapterRepo; + @Autowired private SceneJpaRepository sceneRepo; + @Autowired private CharacterJpaRepository characterRepo; + @Autowired private NpcJpaRepository npcRepo; + @Autowired private EnemyJpaRepository enemyRepo; + @Autowired private ItemCatalogJpaRepository itemCatalogRepo; + @Autowired private RandomTableJpaRepository randomTableRepo; + @Autowired private ImageJpaRepository imageRepo; + @Autowired private StoredFileJpaRepository storedFileRepo; + + @MockitoBean private ImageStorage imageStorage; + @MockitoBean private FileStorage fileStorage; + + private static final String IMG_KEY = "images/round-trip-abc.png"; + private static final String FILE_KEY = "files/round-trip-map.json"; + + @Test + void roundTrip_duplicatesEveryContentTypeAndRemapsReferences() throws IOException { + // ----- 1. Graphe de contenu riche ----- + GameSystemJpaEntity gs = gameSystemRepo.save(GameSystemJpaEntity.builder() + .name("RT System").description("d").foundryActorType("npc").isPublic(true).build()); + + LoreJpaEntity lore = loreRepo.save(LoreJpaEntity.builder().name("RT Lore").description("d").build()); + LoreNodeJpaEntity rootNode = loreNodeRepo.save(LoreNodeJpaEntity.builder() + .name("RT Root").loreId(lore.getId()).order(0).build()); + loreNodeRepo.save(LoreNodeJpaEntity.builder() + .name("RT Child").loreId(lore.getId()).parentId(rootNode.getId()).order(1).build()); + TemplateJpaEntity template = templateRepo.save(TemplateJpaEntity.builder() + .loreId(lore.getId()).name("RT Template").defaultNodeId(rootNode.getId()).build()); + PageJpaEntity page = pageRepo.save(PageJpaEntity.builder() + .loreId(lore.getId()).nodeId(rootNode.getId()).templateId(template.getId()) + .title("RT Page").imageValues(Map.of("gallery", List.of(IMG_KEY))).build()); + // Auto-référence : vérifie le remap des relatedPageIds (2e passe). + page.setRelatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId())))); + pageRepo.save(page); + + imageRepo.save(ImageJpaEntity.builder() + .filename("abc.png").contentType("image/png").sizeBytes(7).storageKey(IMG_KEY).build()); + StoredFileJpaEntity storedFile = storedFileRepo.save(StoredFileJpaEntity.builder() + .filename("map.json").contentType("application/json").sizeBytes(6).storageKey(FILE_KEY).build()); + + CampaignJpaEntity campaign = campaignRepo.save(CampaignJpaEntity.builder() + .name("RT Campaign").description("d").arcsCount(1) + .loreId(String.valueOf(lore.getId())).gameSystemId(String.valueOf(gs.getId())).build()); + + ArcJpaEntity arc = arcRepo.save(ArcJpaEntity.builder() + .name("RT Arc").campaignId(campaign.getId()).order(0).type(ArcType.HUB) + .relatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId())))) + .illustrationImageIds(new ArrayList<>(List.of(IMG_KEY))).build()); + + ChapterJpaEntity chapterA = chapterRepo.save(ChapterJpaEntity.builder() + .name("RT Chapter A").arcId(arc.getId()).order(0).build()); + chapterRepo.save(ChapterJpaEntity.builder() + .name("RT Chapter B").arcId(arc.getId()).order(1) + .prerequisites(new ArrayList<>(List.of(new Prerequisite.QuestCompleted(String.valueOf(chapterA.getId()))))) + .relatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId())))).build()); + ChapterJpaEntity chapterB = chapterRepo.findAll().stream() + .filter(c -> "RT Chapter B".equals(c.getName())).findFirst().orElseThrow(); + + EnemyJpaEntity enemy = enemyRepo.save(EnemyJpaEntity.builder() + .name("RT Enemy").campaignId(campaign.getId()).level("3").folder("Cave") + .foundryRef("Compendium.x").foundryStats(Map.of("hp", "11")).order(0).build()); + + sceneRepo.save(SceneJpaEntity.builder() + .name("RT Scene").chapterId(chapterB.getId()).order(0) + .enemyIds(new ArrayList<>(List.of(String.valueOf(enemy.getId())))) + .relatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId())))) + .illustrationImageIds(new ArrayList<>(List.of(IMG_KEY))) + .battlemapMediaFileId(String.valueOf(storedFile.getId())) + .branches(new ArrayList<>(List.of(new SceneBranch("Si fuite", null, "cond")))).build()); + + npcRepo.save(NpcJpaEntity.builder() + .name("RT Npc").campaignId(campaign.getId()).portraitImageId(IMG_KEY).folder("Ville").order(0) + .relatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId())))).build()); + + characterRepo.save(CharacterJpaEntity.builder() + .name("RT Hero").campaignId(campaign.getId()).playthroughId(999L).order(0).build()); + + ItemCatalogJpaEntity catalog = ItemCatalogJpaEntity.builder() + .name("RT Catalog").campaignId(campaign.getId()).order(0).build(); + CatalogItemJpaEntity item = CatalogItemJpaEntity.builder() + .name("Sword").price("10 gp").category("weapon").position(0).catalog(catalog).build(); + catalog.setItems(new ArrayList<>(List.of(item))); + itemCatalogRepo.save(catalog); + + RandomTableJpaEntity table = RandomTableJpaEntity.builder() + .name("RT Table").campaignId(campaign.getId()).diceFormula("1d6").order(0).build(); + RandomTableEntryJpaEntity entry = RandomTableEntryJpaEntity.builder() + .minRoll(1).maxRoll(3).label("Gobelins").position(0).randomTable(table).build(); + table.setEntries(new ArrayList<>(List.of(entry))); + randomTableRepo.save(table); + + // Comptes AVANT export (incluent un éventuel seed) — l'export est GLOBAL. + long campaignsBefore = campaignRepo.count(); + long arcsBefore = arcRepo.count(); + long chaptersBefore = chapterRepo.count(); + long scenesBefore = sceneRepo.count(); + long npcsBefore = npcRepo.count(); + long enemiesBefore = enemyRepo.count(); + long pagesBefore = pageRepo.count(); + long gsBefore = gameSystemRepo.count(); + long imagesBefore = imageRepo.count(); + Long campaignId0 = campaign.getId(); + Long pageId0 = page.getId(); + + when(imageStorage.download(IMG_KEY)).thenAnswer(inv -> new ByteArrayInputStream("PNGDATA".getBytes())); + when(fileStorage.download(FILE_KEY)).thenAnswer(inv -> new ByteArrayInputStream("{\"x\":1}".getBytes())); + + // ----- 2. Export logique + sérialisation ZIP ----- + ContentExport export = exportService.buildExport("2026-01-02T03:04:05Z"); + assertEquals(1, export.manifest().formatVersion()); + assertEquals("2026-01-02T03:04:05Z", export.manifest().exportedAt()); + assertNotNull(export.manifest().appVersion()); + assertFalse(export.campaigns().isEmpty()); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + exportService.writeZip(export, baos); + Map zip = readZip(baos.toByteArray()); + assertTrue(zip.containsKey("manifest.json")); + assertTrue(zip.containsKey("data.json")); + // Binaire image référencé (portrait/illustration) embarqué + fichier de battlemap. + assertTrue(zip.containsKey("images/" + IMG_KEY), "binaire image référencé attendu dans le zip"); + assertTrue(zip.containsKey("files/" + FILE_KEY), "binaire fichier référencé attendu dans le zip"); + + // ----- 3. Import (mode fusion : nouveaux ids) ----- + ImportResult result = importService.importZip(new ByteArrayInputStream(baos.toByteArray())); + + // Tout le contenu exporté est ré-inséré → chaque type est doublé. + assertEquals(2 * campaignsBefore, campaignRepo.count()); + assertEquals(2 * arcsBefore, arcRepo.count()); + assertEquals(2 * chaptersBefore, chapterRepo.count()); + assertEquals(2 * scenesBefore, sceneRepo.count()); + assertEquals(2 * npcsBefore, npcRepo.count()); + assertEquals(2 * enemiesBefore, enemyRepo.count()); + assertEquals(2 * pagesBefore, pageRepo.count()); + assertEquals(2 * gsBefore, gameSystemRepo.count()); + // Clé image UNIQUE déjà présente → réutilisée, pas de doublon. + assertEquals(imagesBefore, imageRepo.count()); + assertTrue(result.imagesReused() >= 1, "l'image référencée doit être réutilisée"); + assertEquals((int) campaignsBefore, result.created().get("campaigns")); + + // ----- 4. Remapping des références (2e passe) ----- + // 4a. L'arc importé référence la PAGE importée (remappée), pas l'originale. + Long importedCampaignId = onlyOther(campaignRepo.findAll().stream() + .filter(c -> "RT Campaign".equals(c.getName())).map(CampaignJpaEntity::getId).toList(), campaignId0); + Long importedPageId = onlyOther(pageRepo.findAll().stream() + .filter(p -> "RT Page".equals(p.getTitle())).map(PageJpaEntity::getId).toList(), pageId0); + ArcJpaEntity importedArc = arcRepo.findAll().stream() + .filter(a -> "RT Arc".equals(a.getName()) && importedCampaignId.equals(a.getCampaignId())) + .findFirst().orElseThrow(); + assertTrue(importedArc.getRelatedPageIds().contains(String.valueOf(importedPageId)), + "l'arc importé doit pointer la page importée (remap), pas l'originale"); + assertFalse(importedArc.getRelatedPageIds().contains(String.valueOf(pageId0))); + + // 4b. Le personnage importé a son playthroughId remis à null (hors périmètre). + List heroes = characterRepo.findAll().stream() + .filter(c -> "RT Hero".equals(c.getName())).toList(); + assertEquals(2, heroes.size()); + assertTrue(heroes.stream().anyMatch(c -> c.getPlaythroughId() == null)); + assertTrue(heroes.stream().anyMatch(c -> Long.valueOf(999L).equals(c.getPlaythroughId()))); + } + + /** Lit toutes les entrées (nom → octets) d'un zip en mémoire. */ + private static Map readZip(byte[] bytes) throws IOException { + Map out = new LinkedHashMap<>(); + try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(bytes))) { + ZipEntry e; + while ((e = zip.getNextEntry()) != null) { + if (!e.isDirectory()) { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + zip.transferTo(buf); + out.put(e.getName(), buf.toByteArray()); + } + zip.closeEntry(); + } + } + return out; + } + + /** Renvoie l'unique id de la liste différent de {@code original} (l'élément importé). */ + private static Long onlyOther(List ids, Long original) { + return ids.stream().filter(id -> !id.equals(original)).findFirst().orElseThrow(); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/pdf/PdfExportServiceRichTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/pdf/PdfExportServiceRichTest.java new file mode 100644 index 0000000..75ff8b4 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/transfer/pdf/PdfExportServiceRichTest.java @@ -0,0 +1,166 @@ +package com.loremind.infrastructure.transfer.pdf; + +import com.loremind.domain.files.ports.FileStorage; +import com.loremind.domain.images.ports.ImageStorage; +import com.loremind.domain.shared.template.FieldType; +import com.loremind.domain.shared.template.TemplateField; +import com.loremind.infrastructure.persistence.entity.*; +import com.loremind.infrastructure.persistence.jpa.*; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * Export PDF — couverture des sections RICHES non exercées par {@link PdfExportServiceTest} : + * structure narrative (arc → quête → scène), battlemap, lore (pages groupées par chemin de + * dossier + champs pilotés par template TEXT/KEY_VALUE/IMAGE/TABLE), portraits, et + * statistiques Foundry nettoyées. Le stockage binaire est mocké pour fournir un VRAI PNG + * (encode/redimensionnement réellement exécuté) et des cas dégradés (binaire illisible, vidéo). + */ +@SpringBootTest +@Transactional +class PdfExportServiceRichTest { + + @Autowired private PdfExportService service; + @Autowired private CampaignJpaRepository campaignRepo; + @Autowired private GameSystemJpaRepository gameSystemRepo; + @Autowired private ArcJpaRepository arcRepo; + @Autowired private ChapterJpaRepository chapterRepo; + @Autowired private SceneJpaRepository sceneRepo; + @Autowired private NpcJpaRepository npcRepo; + @Autowired private EnemyJpaRepository enemyRepo; + @Autowired private ImageJpaRepository imageRepo; + @Autowired private StoredFileJpaRepository storedFileRepo; + @Autowired private LoreJpaRepository loreRepo; + @Autowired private LoreNodeJpaRepository loreNodeRepo; + @Autowired private PageJpaRepository pageRepo; + @Autowired private TemplateJpaRepository templateRepo; + + @MockitoBean private ImageStorage imageStorage; + @MockitoBean private FileStorage fileStorage; + + @Test + void exportsRichCampaign_narrativeLorePersonasAndImages() throws Exception { + byte[] png = tinyPng(); + // Image décodable vs binaire corrompu (branche encode -> null). + ImageJpaEntity good = imageRepo.save(ImageJpaEntity.builder() + .filename("good.png").contentType("image/png").sizeBytes(png.length).storageKey("images/good.png").build()); + ImageJpaEntity bad = imageRepo.save(ImageJpaEntity.builder() + .filename("bad.png").contentType("image/png").sizeBytes(3).storageKey("images/bad.png").build()); + when(imageStorage.download(eq("images/good.png"))).thenAnswer(inv -> new ByteArrayInputStream(png)); + when(imageStorage.download(eq("images/bad.png"))).thenAnswer(inv -> new ByteArrayInputStream("xxx".getBytes())); + + // Battlemap image (rendue) vs vidéo (ignorée car non rendable en PDF). + StoredFileJpaEntity mapImg = storedFileRepo.save(StoredFileJpaEntity.builder() + .filename("map.png").contentType("image/png").sizeBytes(png.length).storageKey("files/map.png").build()); + StoredFileJpaEntity clip = storedFileRepo.save(StoredFileJpaEntity.builder() + .filename("clip.mp4").contentType("video/mp4").sizeBytes(10).storageKey("files/clip.mp4").build()); + when(fileStorage.download(eq("files/map.png"))).thenAnswer(inv -> new ByteArrayInputStream(png)); + + // Système de jeu : templates PNJ (TEXT + KEY_VALUE_LIST + IMAGE) et ennemi (TEXT). + GameSystemJpaEntity gs = gameSystemRepo.save(GameSystemJpaEntity.builder() + .name("RichSys") + .npcTemplate(List.of( + new TemplateField("Apparence", FieldType.TEXT), + TemplateField.keyValueList("Caractéristiques", List.of("Force", "Dextérité")), + new TemplateField("Galerie", FieldType.IMAGE))) + .enemyTemplate(List.of(new TemplateField("Tactique", FieldType.TEXT))) + .build()); + + // Lore : arbre Géographie / Villes + page pilotée par template (TEXT + TABLE). + LoreJpaEntity lore = loreRepo.save(LoreJpaEntity.builder().name("Atlas").build()); + Long lid = lore.getId(); + LoreNodeJpaEntity geo = loreNodeRepo.save(LoreNodeJpaEntity.builder().name("Géographie").loreId(lid).order(0).build()); + LoreNodeJpaEntity villes = loreNodeRepo.save(LoreNodeJpaEntity.builder().name("Villes").loreId(lid).parentId(geo.getId()).order(0).build()); + TemplateJpaEntity pageTpl = templateRepo.save(TemplateJpaEntity.builder() + .loreId(lid).name("Ville") + .fields(List.of( + new TemplateField("Histoire", FieldType.TEXT), + new TemplateField("Commerces", FieldType.TABLE, null, List.of("Nom", "Prix")))) + .build()); + pageRepo.save(PageJpaEntity.builder() + .loreId(lid).nodeId(villes.getId()).templateId(pageTpl.getId()).title("Padhrad").order(0) + .values(Map.of("Histoire", "Vieille cité portuaire.\nFondée il y a mille ans.")) + .tableValues(Map.of("Commerces", List.of(Map.of("Nom", "Forge de Korr", "Prix", "10 po")))) + .build()); + + CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder() + .name("Campagne Riche & ").description("Desc.\nMultiligne.").arcsCount(1) + .gameSystemId(String.valueOf(gs.getId())).loreId(String.valueOf(lid)).build()); + + // Narration : arc -> quête -> 2 scènes (battlemap image + battlemap vidéo). + ArcJpaEntity arc = arcRepo.save(ArcJpaEntity.builder() + .name("Acte I").campaignId(camp.getId()).order(0) + .description("Le voyage commence.").themes("Trahison").stakes("La cité tombe") + .rewards("Trésor").resolution("Victoire").gmNotes("Secret MJ") + .illustrationImageIds(List.of(String.valueOf(good.getId()))).build()); + ChapterJpaEntity chap = chapterRepo.save(ChapterJpaEntity.builder() + .name("La porte").arcId(arc.getId()).order(0) + .description("Franchir la porte.").playerObjectives("Entrer").narrativeStakes("Le temps presse") + .gmNotes("Piège").build()); + sceneRepo.save(SceneJpaEntity.builder() + .name("L'embuscade").chapterId(chap.getId()).order(0) + .location("Ruelle").timing("Nuit").atmosphere("Tendue").playerNarration("Des ombres bougent") + .gmSecretNotes("3 bandits").choicesConsequences("Fuir ou combattre").combatDifficulty("Moyen") + .battlemapMediaFileId(String.valueOf(mapImg.getId())).build()); + sceneRepo.save(SceneJpaEntity.builder() + .name("La poursuite").chapterId(chap.getId()).order(1) + .battlemapMediaFileId(String.valueOf(clip.getId())).build()); + + // PNJ avec portrait + champs de template (dont galerie d'image corrompue) ; rangé en dossier. + npcRepo.save(NpcJpaEntity.builder() + .campaignId(camp.getId()).name("Maître Orlin").folder("Padhrad").order(0) + .portraitImageId(String.valueOf(good.getId())) + .values(Map.of("Apparence", "Vieillard à barbe blanche")) + .keyValueValues(Map.of("Caractéristiques", Map.of("Force", "8", "Dextérité", "11"))) + .imageValues(Map.of("Galerie", List.of(String.valueOf(bad.getId())))) + .build()); + + // Ennemi avec stats Foundry : bruit (0/false/rollMode) filtré, clés humanisées. + enemyRepo.save(EnemyJpaEntity.builder() + .campaignId(camp.getId()).name("Bandit").folder("Pègre").order(0).level("2") + .values(Map.of("Tactique", "Embuscade")) + .foundryStats(new java.util.LinkedHashMap<>(Map.of( + "attributes.hp.value", "25", + "attributes.ac.value", "0", // bruit (0) -> masqué + "system.details.cr", "3", + "flags.core.rollMode", "gmroll" // technique -> masqué + ))) + .build()); + + byte[] pdf = service.export(String.valueOf(camp.getId())); + + assertNotNull(pdf); + assertTrue(pdf.length > 1000, "le PDF riche doit avoir un contenu substantiel"); + assertEquals("%PDF-", new String(pdf, 0, 5, StandardCharsets.US_ASCII)); + assertEquals("Campagne Riche & ", service.campaignName(String.valueOf(camp.getId()))); + } + + @Test + void export_unknownCampaign_throws() { + assertThrows(java.util.NoSuchElementException.class, () -> service.export("999999999")); + } + + /** Minuscule PNG 4×4 réellement décodable par ImageIO (pour exercer encode()). */ + private static byte[] tinyPng() throws Exception { + BufferedImage img = new BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB); + img.setRGB(0, 0, 0x8A7BC8); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(img, "png", out); + return out.toByteArray(); + } +}