Ajout de tests unitaires pour améliorer la couverture globale des tests

This commit is contained in:
2026-06-26 09:40:01 +02:00
parent a9242ba1e1
commit c1e3a20e5a
10 changed files with 2018 additions and 0 deletions

View File

@@ -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<String> onToken = t -> {};
Runnable onComplete = () -> {};
Consumer<Throwable> 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);
}
}

View File

@@ -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<String, List<PageSummary>> 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"));
}
}

View File

@@ -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<String> 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<String> 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<String> result = service.listForCampaign("camp-1");
assertTrue(result.isEmpty());
}
@Test
void testListForCampaign_NoArcs() {
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
List<String> result = service.listForCampaign("camp-1");
assertTrue(result.isEmpty());
verify(chapterRepository, never()).findByArcId(anyString());
}
}

View File

@@ -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());
}
}

View File

@@ -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<Character> 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<Character> 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<Character> result = service.searchCharacters(" ara ");
assertEquals(1, result.size());
verify(characterRepository).searchByName("ara");
}
}

View File

@@ -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<ItemCatalog> 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<ItemCatalog> 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<ItemCatalog> 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<String> 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"));
}
}

View File

@@ -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<NotebookSource> 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<NotebookSource> 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<String> 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);
}
}

View File

@@ -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<RandomTable> 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<RandomTable> 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<RandomTable> 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<String> 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", "");
}
}

View File

@@ -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.
* <p>
* 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 :
* <ul>
* <li>le zip embarque {@code manifest.json}, {@code data.json} et les binaires
* d'images/fichiers RÉFÉRENCÉS ;</li>
* <li>l'import DOUBLE chaque type (l'export étant global) ;</li>
* <li>les clés d'images (uniques) sont RÉUTILISÉES, pas dupliquées ;</li>
* <li>les références sont remappées en 2e passe (l'arc importé pointe la page
* importée, pas l'originale) ;</li>
* <li>le {@code playthroughId} d'un personnage est remis à null à l'import.</li>
* </ul>
* 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<String, byte[]> 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<CharacterJpaEntity> 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<String, byte[]> readZip(byte[] bytes) throws IOException {
Map<String, byte[]> 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<Long> ids, Long original) {
return ids.stream().filter(id -> !id.equals(original)).findFirst().orElseThrow();
}
}

View File

@@ -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 & <test>").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 & <test>", 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();
}
}