Ajout d'un script pour installation automatique du produit
Ajout d'une partie mise à jour automatique : plus besoin de docker pull en ligne de commande ; on peut passer par l'interface Refactoring partie Java pour respecter d'avantage le DDD : plus de jackson dans la partie domain Passage version 0.6.6
This commit is contained in:
@@ -98,7 +98,7 @@ public class SceneService {
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
for (SceneBranch b : branches) {
|
||||
String target = b.getTargetSceneId();
|
||||
String target = b.targetSceneId();
|
||||
if (target == null || target.isBlank()) {
|
||||
throw new IllegalArgumentException("Une branche doit avoir une scène de destination");
|
||||
}
|
||||
|
||||
@@ -45,11 +45,7 @@ public class GameSystemContextBuilder {
|
||||
private GameSystemContext build(GameSystem gs, GenerationIntent intent) {
|
||||
Map<String, String> allSections = parseH2Sections(gs.getRulesMarkdown());
|
||||
Map<String, String> filtered = filterByIntent(allSections, intent);
|
||||
return GameSystemContext.builder()
|
||||
.systemName(gs.getName())
|
||||
.systemDescription(gs.getDescription())
|
||||
.sections(filtered)
|
||||
.build();
|
||||
return new GameSystemContext(gs.getName(), gs.getDescription(), filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -79,12 +79,11 @@ public class CampaignStructuralContextBuilder {
|
||||
.map(this::toCharacterSummary)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return CampaignStructuralContext.builder()
|
||||
.campaignName(campaign.getName())
|
||||
.campaignDescription(campaign.getDescription())
|
||||
.arcs(arcs)
|
||||
.characters(characters)
|
||||
.build();
|
||||
return new CampaignStructuralContext(
|
||||
campaign.getName(),
|
||||
campaign.getDescription(),
|
||||
arcs,
|
||||
characters);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,10 +92,7 @@ public class CampaignStructuralContextBuilder {
|
||||
* sans injecter toute sa fiche.
|
||||
*/
|
||||
private CharacterSummary toCharacterSummary(Character c) {
|
||||
return CharacterSummary.builder()
|
||||
.name(c.getName())
|
||||
.snippet(extractSnippet(c.getMarkdownContent()))
|
||||
.build();
|
||||
return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent()));
|
||||
}
|
||||
|
||||
private static String extractSnippet(String markdown) {
|
||||
@@ -115,12 +111,11 @@ public class CampaignStructuralContextBuilder {
|
||||
.sorted(Comparator.comparingInt(Chapter::getOrder))
|
||||
.map(this::toChapterSummary)
|
||||
.collect(Collectors.toList());
|
||||
return ArcSummary.builder()
|
||||
.name(arc.getName())
|
||||
.description(arc.getDescription())
|
||||
.illustrationCount(countImages(arc.getIllustrationImageIds()))
|
||||
.chapters(chapters)
|
||||
.build();
|
||||
return new ArcSummary(
|
||||
arc.getName(),
|
||||
arc.getDescription(),
|
||||
countImages(arc.getIllustrationImageIds()),
|
||||
chapters);
|
||||
}
|
||||
|
||||
private ChapterSummary toChapterSummary(Chapter chapter) {
|
||||
@@ -137,32 +132,28 @@ public class CampaignStructuralContextBuilder {
|
||||
.map(s -> toSceneSummary(s, nameById))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ChapterSummary.builder()
|
||||
.name(chapter.getName())
|
||||
.description(chapter.getDescription())
|
||||
.illustrationCount(countImages(chapter.getIllustrationImageIds()))
|
||||
.scenes(summaries)
|
||||
.build();
|
||||
return new ChapterSummary(
|
||||
chapter.getName(),
|
||||
chapter.getDescription(),
|
||||
countImages(chapter.getIllustrationImageIds()),
|
||||
summaries);
|
||||
}
|
||||
|
||||
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
|
||||
List<BranchHint> hints = scene.getBranches() == null
|
||||
? List.of()
|
||||
: scene.getBranches().stream()
|
||||
.map(b -> BranchHint.builder()
|
||||
.label(b.getLabel())
|
||||
.targetSceneName(nameById.getOrDefault(
|
||||
b.getTargetSceneId(), "(scène inconnue)"))
|
||||
.condition(b.getCondition())
|
||||
.build())
|
||||
.map(b -> new BranchHint(
|
||||
b.label(),
|
||||
nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"),
|
||||
b.condition()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return SceneSummary.builder()
|
||||
.name(scene.getName())
|
||||
.description(scene.getDescription())
|
||||
.illustrationCount(countImages(scene.getIllustrationImageIds()))
|
||||
.branches(hints)
|
||||
.build();
|
||||
return new SceneSummary(
|
||||
scene.getName(),
|
||||
scene.getDescription(),
|
||||
countImages(scene.getIllustrationImageIds()),
|
||||
hints);
|
||||
}
|
||||
|
||||
/** Helper defensif : compte les illustrations attachees (null-safe). */
|
||||
|
||||
@@ -67,16 +67,15 @@ public class GeneratePageValuesUseCase {
|
||||
|
||||
requireNonEmptyFields(template);
|
||||
|
||||
GenerationContext context = GenerationContext.builder()
|
||||
.loreName(lore.getName())
|
||||
.loreDescription(lore.getDescription())
|
||||
.folderName(folder.getName())
|
||||
.templateName(template.getName())
|
||||
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
|
||||
// necessitent un workflow different (pas de generation LLM texte).
|
||||
.templateFields(template.textFieldNames())
|
||||
.pageTitle(page.getTitle())
|
||||
.build();
|
||||
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
|
||||
// necessitent un workflow different (pas de generation LLM texte).
|
||||
GenerationContext context = new GenerationContext(
|
||||
lore.getName(),
|
||||
lore.getDescription(),
|
||||
folder.getName(),
|
||||
template.getName(),
|
||||
template.textFieldNames(),
|
||||
page.getTitle());
|
||||
|
||||
GenerationResult result = aiProvider.generatePage(context);
|
||||
return result.values();
|
||||
|
||||
@@ -82,12 +82,11 @@ public class LoreStructuralContextBuilder {
|
||||
Map<String, String> pageTitleById = pages.stream()
|
||||
.collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a));
|
||||
|
||||
return LoreStructuralContext.builder()
|
||||
.loreName(lore.getName())
|
||||
.loreDescription(lore.getDescription())
|
||||
.folders(buildFoldersMap(nodes, pages, templateNameById, pageTitleById))
|
||||
.tags(extractUniqueTags(pages))
|
||||
.build();
|
||||
return new LoreStructuralContext(
|
||||
lore.getName(),
|
||||
lore.getDescription(),
|
||||
buildFoldersMap(nodes, pages, templateNameById, pageTitleById),
|
||||
extractUniqueTags(pages));
|
||||
}
|
||||
|
||||
private Map<String, List<PageSummary>> buildFoldersMap(
|
||||
@@ -118,13 +117,12 @@ public class LoreStructuralContextBuilder {
|
||||
Page page,
|
||||
Map<String, String> templateNameById,
|
||||
Map<String, String> pageTitleById) {
|
||||
return PageSummary.builder()
|
||||
.title(page.getTitle())
|
||||
.templateName(templateNameById.getOrDefault(page.getTemplateId(), "?"))
|
||||
.values(truncatedValues(page.getValues()))
|
||||
.tags(page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList())
|
||||
.relatedPageTitles(resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById))
|
||||
.build();
|
||||
return new PageSummary(
|
||||
page.getTitle(),
|
||||
templateNameById.getOrDefault(page.getTemplateId(), "?"),
|
||||
truncatedValues(page.getValues()),
|
||||
page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList(),
|
||||
resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -91,11 +91,7 @@ public class NarrativeEntityContextBuilder {
|
||||
putField(fields, "rewards", a.getRewards());
|
||||
putField(fields, "resolution", a.getResolution());
|
||||
putField(fields, "gmNotes", a.getGmNotes());
|
||||
return NarrativeEntityContext.builder()
|
||||
.entityType("arc")
|
||||
.title(a.getName())
|
||||
.fields(fields)
|
||||
.build();
|
||||
return new NarrativeEntityContext("arc", a.getName(), fields);
|
||||
}
|
||||
|
||||
private NarrativeEntityContext fromChapter(Chapter c) {
|
||||
@@ -104,11 +100,7 @@ public class NarrativeEntityContextBuilder {
|
||||
putField(fields, "playerObjectives", c.getPlayerObjectives());
|
||||
putField(fields, "narrativeStakes", c.getNarrativeStakes());
|
||||
putField(fields, "gmNotes", c.getGmNotes());
|
||||
return NarrativeEntityContext.builder()
|
||||
.entityType("chapter")
|
||||
.title(c.getName())
|
||||
.fields(fields)
|
||||
.build();
|
||||
return new NarrativeEntityContext("chapter", c.getName(), fields);
|
||||
}
|
||||
|
||||
private NarrativeEntityContext fromScene(Scene s) {
|
||||
@@ -122,21 +114,13 @@ public class NarrativeEntityContextBuilder {
|
||||
putField(fields, "combatDifficulty", s.getCombatDifficulty());
|
||||
putField(fields, "enemies", s.getEnemies());
|
||||
putField(fields, "gmSecretNotes", s.getGmSecretNotes());
|
||||
return NarrativeEntityContext.builder()
|
||||
.entityType("scene")
|
||||
.title(s.getName())
|
||||
.fields(fields)
|
||||
.build();
|
||||
return new NarrativeEntityContext("scene", s.getName(), fields);
|
||||
}
|
||||
|
||||
private NarrativeEntityContext fromCharacter(Character c) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
|
||||
return NarrativeEntityContext.builder()
|
||||
.entityType("character")
|
||||
.title(c.getName())
|
||||
.fields(fields)
|
||||
.build();
|
||||
return new NarrativeEntityContext("character", c.getName(), fields);
|
||||
}
|
||||
|
||||
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */
|
||||
|
||||
@@ -107,11 +107,6 @@ public class StreamChatForLoreUseCase {
|
||||
? page.getValues()
|
||||
: Collections.emptyMap();
|
||||
|
||||
return PageContext.builder()
|
||||
.title(page.getTitle())
|
||||
.templateName(templateName)
|
||||
.templateFields(templateFields)
|
||||
.values(values)
|
||||
.build();
|
||||
return new PageContext(page.getTitle(), templateName, templateFields, values);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
/**
|
||||
* Value Object représentant une "sortie" narrative depuis une Scene.
|
||||
* Décrit un choix offert aux joueurs et la scène de destination associée.
|
||||
* <p>
|
||||
* Immuable (@Value) : pour "modifier" une branche on la remplace.
|
||||
* @Jacksonized : permet à Jackson (sérialisation JSON via le converter JPA)
|
||||
* de reconstruire l'objet en passant par le builder malgré l'absence de setters.
|
||||
* Record Java : immuable par construction, sans aucune dépendance technique
|
||||
* (pas de Lombok, pas de Jackson). Jackson 2.12+ sait sérialiser/désérialiser
|
||||
* les records nativement via le constructeur canonique — c'est ce dont
|
||||
* dépend le SceneBranchListJsonConverter pour le stockage JSONB.
|
||||
* <p>
|
||||
* Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter
|
||||
* (validation portée par SceneService).
|
||||
*
|
||||
* @param label Libellé du choix (ex: "Si les joueurs attaquent le garde").
|
||||
* @param targetSceneId Id de la Scene de destination, intra-chapitre uniquement.
|
||||
* @param condition Notes MJ privées sur la condition de déclenchement (optionnel).
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
@Jacksonized
|
||||
public class SceneBranch {
|
||||
public record SceneBranch(String label, String targetSceneId, String condition) {
|
||||
|
||||
/** Libellé du choix (ex: "Si les joueurs attaquent le garde"). */
|
||||
String label;
|
||||
|
||||
/** Id de la Scene de destination, intra-chapitre uniquement. */
|
||||
String targetSceneId;
|
||||
|
||||
/** Notes MJ privées sur la condition de déclenchement (optionnel). */
|
||||
String condition;
|
||||
/** Raccourci pour construire une branche sans condition (cas le plus courant). */
|
||||
public static SceneBranch of(String label, String targetSceneId) {
|
||||
return new SceneBranch(label, targetSceneId, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Singular;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -22,16 +18,16 @@ import java.util.List;
|
||||
* <p>
|
||||
* La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant
|
||||
* fait par le use case côté application layer).
|
||||
* <p>
|
||||
* Record Java : pur domaine, aucune dépendance technique.
|
||||
*
|
||||
* @param characters Personnages joueurs (PJ) de la campagne. Vide si aucun.
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class CampaignStructuralContext {
|
||||
|
||||
String campaignName;
|
||||
String campaignDescription;
|
||||
@Singular List<ArcSummary> arcs;
|
||||
/** Personnages joueurs (PJ) de la campagne. Vide si aucun. */
|
||||
@Singular List<CharacterSummary> characters;
|
||||
public record CampaignStructuralContext(
|
||||
String campaignName,
|
||||
String campaignDescription,
|
||||
List<ArcSummary> arcs,
|
||||
List<CharacterSummary> characters) {
|
||||
|
||||
/**
|
||||
* Résumé d'un PJ : nom + snippet court du markdown.
|
||||
@@ -40,53 +36,44 @@ public class CampaignStructuralContext {
|
||||
* La fiche complète n'est injectée que si le PJ est l'entité focus
|
||||
* (via NarrativeEntityContext, entity_type="character").
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public static class CharacterSummary {
|
||||
String name;
|
||||
String snippet;
|
||||
public record CharacterSummary(String name, String snippet) {
|
||||
}
|
||||
|
||||
/** Résumé d'un arc : nom + description courte + ses chapitres. */
|
||||
@Value
|
||||
@Builder
|
||||
public static class ArcSummary {
|
||||
String name;
|
||||
String description;
|
||||
/** Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA). */
|
||||
int illustrationCount;
|
||||
@Singular List<ChapterSummary> chapters;
|
||||
/**
|
||||
* Résumé d'un arc : nom + description courte + ses chapitres.
|
||||
*
|
||||
* @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA).
|
||||
*/
|
||||
public record ArcSummary(
|
||||
String name,
|
||||
String description,
|
||||
int illustrationCount,
|
||||
List<ChapterSummary> chapters) {
|
||||
}
|
||||
|
||||
/** Résumé d'un chapitre : nom + description courte + ses scènes. */
|
||||
@Value
|
||||
@Builder
|
||||
public static class ChapterSummary {
|
||||
String name;
|
||||
String description;
|
||||
int illustrationCount;
|
||||
@Singular List<SceneSummary> scenes;
|
||||
public record ChapterSummary(
|
||||
String name,
|
||||
String description,
|
||||
int illustrationCount,
|
||||
List<SceneSummary> scenes) {
|
||||
}
|
||||
|
||||
/** Résumé d'une scène : nom + description courte + branches narratives. */
|
||||
@Value
|
||||
@Builder
|
||||
public static class SceneSummary {
|
||||
String name;
|
||||
String description;
|
||||
int illustrationCount;
|
||||
@Singular List<BranchHint> branches;
|
||||
public record SceneSummary(
|
||||
String name,
|
||||
String description,
|
||||
int illustrationCount,
|
||||
List<BranchHint> branches) {
|
||||
}
|
||||
|
||||
/** Indice d'une branche narrative vers une autre scène du même chapitre. */
|
||||
@Value
|
||||
@Builder
|
||||
public static class BranchHint {
|
||||
/** Libellé du choix joueur (ex: "Si les joueurs attaquent le garde"). */
|
||||
String label;
|
||||
/** Nom de la scène cible (résolu depuis targetSceneId côté builder). */
|
||||
String targetSceneName;
|
||||
/** Condition MJ privée (optionnel). */
|
||||
String condition;
|
||||
/**
|
||||
* Indice d'une branche narrative vers une autre scène du même chapitre.
|
||||
*
|
||||
* @param label Libellé du choix joueur (ex: "Si les joueurs attaquent le garde").
|
||||
* @param targetSceneName Nom de la scène cible (résolu depuis targetSceneId côté builder).
|
||||
* @param condition Condition MJ privée (optionnel).
|
||||
*/
|
||||
public record BranchHint(String label, String targetSceneName, String condition) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -21,28 +18,74 @@ import java.util.List;
|
||||
* Un chat Lore ne reçoit JAMAIS de campaignContext : un Lore ne voit pas
|
||||
* ses campagnes (asymétrie métier : la campagne est l'emprunteur du Lore,
|
||||
* pas l'inverse).
|
||||
* <p>
|
||||
* Record Java : pur domaine. Builder manuel fourni en raison des 6 champs
|
||||
* dont 5 sont nullables — l'API fluide reste plus lisible aux call sites
|
||||
* qu'un constructeur à 6 paramètres souvent à null.
|
||||
*
|
||||
* @param loreContext Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore.
|
||||
* @param pageContext Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement).
|
||||
* @param campaignContext Optionnel : carte narrative d'une Campagne (chat Campagne uniquement).
|
||||
* @param narrativeEntity Optionnel : entité narrative en cours d'édition (arc/chapter/scene).
|
||||
* @param gameSystemContext Optionnel : règles du système de JDR de la campagne (filtrées par intent).
|
||||
* Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP.
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class ChatRequest {
|
||||
public record ChatRequest(
|
||||
List<ChatMessage> messages,
|
||||
LoreStructuralContext loreContext,
|
||||
PageContext pageContext,
|
||||
CampaignStructuralContext campaignContext,
|
||||
NarrativeEntityContext narrativeEntity,
|
||||
GameSystemContext gameSystemContext) {
|
||||
|
||||
List<ChatMessage> messages;
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
/** Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore. */
|
||||
LoreStructuralContext loreContext;
|
||||
/** Builder fluide : permet d'omettre les contextes non pertinents. */
|
||||
public static final class Builder {
|
||||
private List<ChatMessage> messages;
|
||||
private LoreStructuralContext loreContext;
|
||||
private PageContext pageContext;
|
||||
private CampaignStructuralContext campaignContext;
|
||||
private NarrativeEntityContext narrativeEntity;
|
||||
private GameSystemContext gameSystemContext;
|
||||
|
||||
/** Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement). */
|
||||
PageContext pageContext;
|
||||
private Builder() {}
|
||||
|
||||
/** Optionnel : carte narrative d'une Campagne (chat Campagne uniquement). */
|
||||
CampaignStructuralContext campaignContext;
|
||||
public Builder messages(List<ChatMessage> messages) {
|
||||
this.messages = messages;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */
|
||||
NarrativeEntityContext narrativeEntity;
|
||||
public Builder loreContext(LoreStructuralContext loreContext) {
|
||||
this.loreContext = loreContext;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionnel : règles du système de JDR de la campagne (filtrées par intent).
|
||||
* Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP.
|
||||
*/
|
||||
GameSystemContext gameSystemContext;
|
||||
public Builder pageContext(PageContext pageContext) {
|
||||
this.pageContext = pageContext;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder campaignContext(CampaignStructuralContext campaignContext) {
|
||||
this.campaignContext = campaignContext;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder narrativeEntity(NarrativeEntityContext narrativeEntity) {
|
||||
this.narrativeEntity = narrativeEntity;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder gameSystemContext(GameSystemContext gameSystemContext) {
|
||||
this.gameSystemContext = gameSystemContext;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ChatRequest build() {
|
||||
return new ChatRequest(messages, loreContext, pageContext,
|
||||
campaignContext, narrativeEntity, gameSystemContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -11,20 +8,14 @@ import java.util.Map;
|
||||
* Contient uniquement les sections pertinentes pour l'intent de génération
|
||||
* en cours (sélection effectuée par GameSystemContextBuilder). Les sections
|
||||
* sont indexées par leur titre H2 original (ex : "Combat", "Classes").
|
||||
*
|
||||
* @param systemName Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD").
|
||||
* @param systemDescription Description courte du système (nullable).
|
||||
* @param sections Sections de règles pertinentes, indexées par titre H2.
|
||||
* Vide si le GameSystem n'a aucune règle ou si aucune section ne matche l'intent.
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class GameSystemContext {
|
||||
|
||||
/** Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD"). */
|
||||
String systemName;
|
||||
|
||||
/** Description courte du système (nullable). */
|
||||
String systemDescription;
|
||||
|
||||
/**
|
||||
* Sections de règles pertinentes, indexées par titre H2.
|
||||
* Vide si le GameSystem n'a aucune règle ou si aucune section ne matche l'intent.
|
||||
*/
|
||||
Map<String, String> sections;
|
||||
public record GameSystemContext(
|
||||
String systemName,
|
||||
String systemDescription,
|
||||
Map<String, String> sections) {
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -10,19 +7,16 @@ import java.util.List;
|
||||
* pour remplir une Page à partir d'un Template.
|
||||
* <p>
|
||||
* Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py).
|
||||
* Entité pure du domaine : aucune dépendance technique.
|
||||
* <p>
|
||||
* Immuable via @Value (Lombok) : pas de setters, tous les champs final.
|
||||
* C'est un DTO de domaine entrant dans le port AiProvider.
|
||||
* Record Java : pur domaine, aucune dépendance technique.
|
||||
*
|
||||
* @param templateFields Champs à générer (clés attendues dans la réponse).
|
||||
* @param folderName Nom du LoreNode parent (ex: "PNJ", "Lieux").
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class GenerationContext {
|
||||
|
||||
String loreName;
|
||||
String loreDescription;
|
||||
String folderName; // Nom du LoreNode parent (ex: "PNJ", "Lieux")
|
||||
String templateName;
|
||||
List<String> templateFields; // Champs à générer (clés attendues dans la réponse)
|
||||
String pageTitle;
|
||||
public record GenerationContext(
|
||||
String loreName,
|
||||
String loreDescription,
|
||||
String folderName,
|
||||
String templateName,
|
||||
List<String> templateFields,
|
||||
String pageTitle) {
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Singular;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -16,15 +12,14 @@ import java.util.Map;
|
||||
* <p>
|
||||
* La map `folders` est indexée par nom de dossier et mappe vers la liste
|
||||
* des pages qu'il contient (liste vide autorisée pour les dossiers vides).
|
||||
* <p>
|
||||
* Record Java : pur domaine, aucune dépendance technique.
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class LoreStructuralContext {
|
||||
|
||||
String loreName;
|
||||
String loreDescription;
|
||||
Map<String, List<PageSummary>> folders;
|
||||
@Singular List<String> tags;
|
||||
public record LoreStructuralContext(
|
||||
String loreName,
|
||||
String loreDescription,
|
||||
Map<String, List<PageSummary>> folders,
|
||||
List<String> tags) {
|
||||
|
||||
/**
|
||||
* Résumé projeté d'une page pour l'IA.
|
||||
@@ -40,13 +35,11 @@ public class LoreStructuralContext {
|
||||
* uniquement ce qui est partageable en narration — les secrets MJ
|
||||
* restent confinés à leur page d'édition).
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public static class PageSummary {
|
||||
String title;
|
||||
String templateName;
|
||||
Map<String, String> values;
|
||||
List<String> tags;
|
||||
List<String> relatedPageTitles;
|
||||
public record PageSummary(
|
||||
String title,
|
||||
String templateName,
|
||||
Map<String, String> values,
|
||||
List<String> tags,
|
||||
List<String> relatedPageTitles) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -17,13 +14,11 @@ import java.util.Map;
|
||||
* `fields` associe le nom d'un champ (ex: "themes", "playerNarration")
|
||||
* à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une
|
||||
* LinkedHashMap à la construction pour un prompt lisible (ordre préservé).
|
||||
*
|
||||
* @param entityType "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt.
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class NarrativeEntityContext {
|
||||
|
||||
/** "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt. */
|
||||
String entityType;
|
||||
String title;
|
||||
Map<String, String> fields;
|
||||
public record NarrativeEntityContext(
|
||||
String entityType,
|
||||
String title,
|
||||
Map<String, String> fields) {
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -14,14 +11,11 @@ import java.util.Map;
|
||||
* à l'IA de focaliser ses suggestions sur les bons champs sans déborder
|
||||
* sur d'autres pages/templates.
|
||||
* <p>
|
||||
* Object de valeur immuable, pur domaine — aucune dépendance technique.
|
||||
* Record Java : immuable, pur domaine, aucune dépendance technique.
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class PageContext {
|
||||
|
||||
String title;
|
||||
String templateName;
|
||||
List<String> templateFields;
|
||||
Map<String, String> values;
|
||||
public record PageContext(
|
||||
String title,
|
||||
String templateName,
|
||||
List<String> templateFields,
|
||||
Map<String, String> values) {
|
||||
}
|
||||
|
||||
@@ -53,12 +53,12 @@ public class BrainAiClient implements AiProvider {
|
||||
|
||||
private BrainGeneratePageRequest toBrainRequest(GenerationContext context) {
|
||||
return new BrainGeneratePageRequest(
|
||||
context.getLoreName(),
|
||||
context.getLoreDescription(),
|
||||
context.getFolderName(),
|
||||
context.getTemplateName(),
|
||||
context.getTemplateFields(),
|
||||
context.getPageTitle()
|
||||
context.loreName(),
|
||||
context.loreDescription(),
|
||||
context.folderName(),
|
||||
context.templateName(),
|
||||
context.templateFields(),
|
||||
context.pageTitle()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,35 +38,35 @@ public class BrainChatPayloadBuilder {
|
||||
|
||||
public Map<String, Object> build(ChatRequest request) {
|
||||
Map<String, Object> root = new LinkedHashMap<>();
|
||||
root.put("messages", request.getMessages().stream()
|
||||
root.put("messages", request.messages().stream()
|
||||
.map(this::messageToMap)
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
if (request.getLoreContext() != null) {
|
||||
root.put("lore_context", loreContextToMap(request.getLoreContext()));
|
||||
if (request.loreContext() != null) {
|
||||
root.put("lore_context", loreContextToMap(request.loreContext()));
|
||||
}
|
||||
if (request.getPageContext() != null) {
|
||||
root.put("page_context", pageContextToMap(request.getPageContext()));
|
||||
if (request.pageContext() != null) {
|
||||
root.put("page_context", pageContextToMap(request.pageContext()));
|
||||
}
|
||||
if (request.getCampaignContext() != null) {
|
||||
root.put("campaign_context", campaignContextToMap(request.getCampaignContext()));
|
||||
if (request.campaignContext() != null) {
|
||||
root.put("campaign_context", campaignContextToMap(request.campaignContext()));
|
||||
}
|
||||
if (request.getNarrativeEntity() != null) {
|
||||
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
|
||||
if (request.narrativeEntity() != null) {
|
||||
root.put("narrative_entity", narrativeEntityToMap(request.narrativeEntity()));
|
||||
}
|
||||
if (request.getGameSystemContext() != null) {
|
||||
root.put("game_system_context", gameSystemContextToMap(request.getGameSystemContext()));
|
||||
if (request.gameSystemContext() != null) {
|
||||
root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext()));
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("system_name", gs.getSystemName());
|
||||
if (gs.getSystemDescription() != null && !gs.getSystemDescription().isBlank()) {
|
||||
map.put("system_description", gs.getSystemDescription());
|
||||
map.put("system_name", gs.systemName());
|
||||
if (gs.systemDescription() != null && !gs.systemDescription().isBlank()) {
|
||||
map.put("system_description", gs.systemDescription());
|
||||
}
|
||||
map.put("sections", gs.getSections() != null ? gs.getSections() : Map.of());
|
||||
map.put("sections", gs.sections() != null ? gs.sections() : Map.of());
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -79,56 +79,56 @@ public class BrainChatPayloadBuilder {
|
||||
|
||||
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("lore_name", ctx.getLoreName());
|
||||
map.put("lore_description", ctx.getLoreDescription());
|
||||
map.put("lore_name", ctx.loreName());
|
||||
map.put("lore_description", ctx.loreDescription());
|
||||
|
||||
Map<String, Object> foldersMap = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<PageSummary>> e : ctx.getFolders().entrySet()) {
|
||||
for (Map.Entry<String, List<PageSummary>> e : ctx.folders().entrySet()) {
|
||||
foldersMap.put(e.getKey(), e.getValue().stream()
|
||||
.map(this::pageSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
map.put("folders", foldersMap);
|
||||
map.put("tags", ctx.getTags());
|
||||
map.put("tags", ctx.tags());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("title", ps.getTitle());
|
||||
map.put("template_name", ps.getTemplateName());
|
||||
map.put("title", ps.title());
|
||||
map.put("template_name", ps.templateName());
|
||||
// values/tags/related_page_titles : omis si vides pour alléger le payload.
|
||||
if (ps.getValues() != null && !ps.getValues().isEmpty()) {
|
||||
map.put("values", ps.getValues());
|
||||
if (ps.values() != null && !ps.values().isEmpty()) {
|
||||
map.put("values", ps.values());
|
||||
}
|
||||
if (ps.getTags() != null && !ps.getTags().isEmpty()) {
|
||||
map.put("tags", ps.getTags());
|
||||
if (ps.tags() != null && !ps.tags().isEmpty()) {
|
||||
map.put("tags", ps.tags());
|
||||
}
|
||||
if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) {
|
||||
map.put("related_page_titles", ps.getRelatedPageTitles());
|
||||
if (ps.relatedPageTitles() != null && !ps.relatedPageTitles().isEmpty()) {
|
||||
map.put("related_page_titles", ps.relatedPageTitles());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> pageContextToMap(PageContext pc) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("title", pc.getTitle());
|
||||
map.put("template_name", pc.getTemplateName());
|
||||
map.put("template_fields", pc.getTemplateFields());
|
||||
map.put("values", pc.getValues());
|
||||
map.put("title", pc.title());
|
||||
map.put("template_name", pc.templateName());
|
||||
map.put("template_fields", pc.templateFields());
|
||||
map.put("values", pc.values());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("campaign_name", ctx.getCampaignName());
|
||||
map.put("campaign_description", ctx.getCampaignDescription());
|
||||
map.put("arcs", ctx.getArcs().stream()
|
||||
map.put("campaign_name", ctx.campaignName());
|
||||
map.put("campaign_description", ctx.campaignDescription());
|
||||
map.put("arcs", ctx.arcs().stream()
|
||||
.map(this::arcSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
|
||||
if (ctx.getCharacters() != null && !ctx.getCharacters().isEmpty()) {
|
||||
map.put("characters", ctx.getCharacters().stream()
|
||||
if (ctx.characters() != null && !ctx.characters().isEmpty()) {
|
||||
map.put("characters", ctx.characters().stream()
|
||||
.map(this::characterSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
@@ -137,9 +137,9 @@ public class BrainChatPayloadBuilder {
|
||||
|
||||
private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", c.getName());
|
||||
if (c.getSnippet() != null && !c.getSnippet().isBlank()) {
|
||||
map.put("snippet", c.getSnippet());
|
||||
map.put("name", c.name());
|
||||
if (c.snippet() != null && !c.snippet().isBlank()) {
|
||||
map.put("snippet", c.snippet());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -167,10 +167,10 @@ public class BrainChatPayloadBuilder {
|
||||
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
|
||||
return structuralSummaryToMap(
|
||||
a,
|
||||
ArcSummary::getName,
|
||||
ArcSummary::getDescription,
|
||||
ArcSummary::getIllustrationCount,
|
||||
(map, arc) -> map.put("chapters", arc.getChapters().stream()
|
||||
ArcSummary::name,
|
||||
ArcSummary::description,
|
||||
ArcSummary::illustrationCount,
|
||||
(map, arc) -> map.put("chapters", arc.chapters().stream()
|
||||
.map(this::chapterSummaryToMap)
|
||||
.collect(Collectors.toList())));
|
||||
}
|
||||
@@ -178,10 +178,10 @@ public class BrainChatPayloadBuilder {
|
||||
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
||||
return structuralSummaryToMap(
|
||||
c,
|
||||
ChapterSummary::getName,
|
||||
ChapterSummary::getDescription,
|
||||
ChapterSummary::getIllustrationCount,
|
||||
(map, chapter) -> map.put("scenes", chapter.getScenes().stream()
|
||||
ChapterSummary::name,
|
||||
ChapterSummary::description,
|
||||
ChapterSummary::illustrationCount,
|
||||
(map, chapter) -> map.put("scenes", chapter.scenes().stream()
|
||||
.map(this::sceneSummaryToMap)
|
||||
.collect(Collectors.toList())));
|
||||
}
|
||||
@@ -189,13 +189,13 @@ public class BrainChatPayloadBuilder {
|
||||
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
|
||||
return structuralSummaryToMap(
|
||||
s,
|
||||
SceneSummary::getName,
|
||||
SceneSummary::getDescription,
|
||||
SceneSummary::getIllustrationCount,
|
||||
SceneSummary::name,
|
||||
SceneSummary::description,
|
||||
SceneSummary::illustrationCount,
|
||||
(map, scene) -> {
|
||||
// Branches narratives : omises si absentes (scènes linéaires classiques).
|
||||
if (s.getBranches() != null && !s.getBranches().isEmpty()) {
|
||||
map.put("branches", s.getBranches().stream()
|
||||
if (s.branches() != null && !s.branches().isEmpty()) {
|
||||
map.put("branches", s.branches().stream()
|
||||
.map(this::branchHintToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
@@ -204,19 +204,19 @@ public class BrainChatPayloadBuilder {
|
||||
|
||||
private Map<String, Object> branchHintToMap(BranchHint b) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("label", b.getLabel());
|
||||
map.put("target_scene_name", b.getTargetSceneName());
|
||||
if (b.getCondition() != null && !b.getCondition().isBlank()) {
|
||||
map.put("condition", b.getCondition());
|
||||
map.put("label", b.label());
|
||||
map.put("target_scene_name", b.targetSceneName());
|
||||
if (b.condition() != null && !b.condition().isBlank()) {
|
||||
map.put("condition", b.condition());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("entity_type", ne.getEntityType());
|
||||
map.put("title", ne.getTitle());
|
||||
map.put("fields", ne.getFields());
|
||||
map.put("entity_type", ne.entityType());
|
||||
map.put("title", ne.title());
|
||||
map.put("fields", ne.fields());
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
package com.loremind.infrastructure.updates;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Detection des mises a jour disponibles + declenchement via Watchtower.
|
||||
*
|
||||
* Strategie :
|
||||
* - Au demarrage, on interroge le registry pour le digest courant de chaque
|
||||
* image suivie ({@code update-check.images}). On stocke ces digests comme
|
||||
* "baseline" (= ce que le conteneur en cours d'execution est cense faire
|
||||
* tourner, puisque le `docker compose pull` precede toujours `up -d`).
|
||||
* - {@link #check()} re-interroge le registry et compare. Si un digest a
|
||||
* change, une mise a jour est disponible.
|
||||
* - {@link #apply()} POST sur /v1/update de Watchtower (qui doit etre lance
|
||||
* avec WATCHTOWER_HTTP_API_UPDATE=true et le meme token).
|
||||
*
|
||||
* Apres un apply reussi, Watchtower redemarre core => ce service est
|
||||
* re-instancie => baseline re-aligne sur le registry => check renvoie
|
||||
* "pas de MAJ" (etat coherent).
|
||||
*
|
||||
* La feature est <b>desactivee silencieusement</b> si {@code WATCHTOWER_TOKEN}
|
||||
* n'est pas defini : check/apply renvoient des reponses neutres et l'UI
|
||||
* masque le badge / bouton.
|
||||
*/
|
||||
@Service
|
||||
public class UpdateCheckService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UpdateCheckService.class);
|
||||
|
||||
private static final List<MediaType> MANIFEST_ACCEPT = List.of(
|
||||
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.v2+json"),
|
||||
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.list.v2+json"),
|
||||
MediaType.parseMediaType("application/vnd.oci.image.manifest.v1+json"),
|
||||
MediaType.parseMediaType("application/vnd.oci.image.index.v1+json")
|
||||
);
|
||||
|
||||
private final RestTemplate http;
|
||||
private final String registry;
|
||||
private final List<String> images;
|
||||
private final String tag;
|
||||
private final String watchtowerUrl;
|
||||
private final String watchtowerToken;
|
||||
|
||||
private final Map<String, String> baselineDigests = new ConcurrentHashMap<>();
|
||||
|
||||
public UpdateCheckService(
|
||||
RestTemplateBuilder builder,
|
||||
@Value("${update-check.registry:}") String registry,
|
||||
@Value("${update-check.images:}") String imagesCsv,
|
||||
@Value("${update-check.tag:latest}") String tag,
|
||||
@Value("${update-check.watchtower-url:http://watchtower:8080}") String watchtowerUrl,
|
||||
@Value("${update-check.watchtower-token:}") String watchtowerToken) {
|
||||
this.http = builder
|
||||
.setConnectTimeout(Duration.ofSeconds(5))
|
||||
.setReadTimeout(Duration.ofSeconds(15))
|
||||
.build();
|
||||
this.registry = normalizeRegistry(registry);
|
||||
this.images = parseImages(imagesCsv);
|
||||
this.tag = tag;
|
||||
this.watchtowerUrl = watchtowerUrl;
|
||||
this.watchtowerToken = watchtowerToken;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void initBaseline() {
|
||||
if (!isEnabled()) {
|
||||
log.info("Update check disabled (WATCHTOWER_TOKEN not set)");
|
||||
return;
|
||||
}
|
||||
log.info("Update check enabled - registry={} images={} tag={}", registry, images, tag);
|
||||
for (String image : images) {
|
||||
try {
|
||||
String digest = fetchRemoteDigest(image);
|
||||
if (digest != null) {
|
||||
baselineDigests.put(image, digest);
|
||||
log.debug("Baseline digest for {} = {}", image, digest);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Cannot baseline digest for {}: {}", image, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty();
|
||||
}
|
||||
|
||||
public UpdateStatus check() {
|
||||
if (!isEnabled()) {
|
||||
return new UpdateStatus(false, false, List.of(), Instant.now());
|
||||
}
|
||||
List<ImageStatus> statuses = new ArrayList<>();
|
||||
boolean anyUpdate = false;
|
||||
for (String image : images) {
|
||||
String baseline = baselineDigests.get(image);
|
||||
String remote = null;
|
||||
try {
|
||||
remote = fetchRemoteDigest(image);
|
||||
} catch (Exception e) {
|
||||
log.warn("Check failed for {}: {}", image, e.getMessage());
|
||||
}
|
||||
// Si on n'a pas de baseline (echec au boot), on l'aligne maintenant
|
||||
// pour eviter un faux positif "MAJ dispo".
|
||||
if (baseline == null && remote != null) {
|
||||
baselineDigests.put(image, remote);
|
||||
baseline = remote;
|
||||
}
|
||||
boolean updateAvailable = baseline != null && remote != null && !baseline.equals(remote);
|
||||
if (updateAvailable) anyUpdate = true;
|
||||
statuses.add(new ImageStatus(image, baseline, remote, updateAvailable));
|
||||
}
|
||||
return new UpdateStatus(true, anyUpdate, statuses, Instant.now());
|
||||
}
|
||||
|
||||
public void apply() {
|
||||
if (!isEnabled()) {
|
||||
throw new IllegalStateException("Update apply not configured (WATCHTOWER_TOKEN missing)");
|
||||
}
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setBearerAuth(watchtowerToken);
|
||||
// Watchtower /v1/update declenche un scan+update immediat de tous les
|
||||
// conteneurs labellises. La reponse est synchrone et peut prendre
|
||||
// plusieurs secondes; en cas de redemarrage de core, le client
|
||||
// recevra une connexion coupee — c'est attendu, l'UI le gere.
|
||||
http.exchange(
|
||||
watchtowerUrl + "/v1/update",
|
||||
HttpMethod.POST,
|
||||
new HttpEntity<>(headers),
|
||||
Void.class);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Registry HTTP API v2
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private String fetchRemoteDigest(String image) {
|
||||
String url = registry + "/v2/" + image + "/manifests/" + tag;
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setAccept(MANIFEST_ACCEPT);
|
||||
try {
|
||||
return digestCall(url, headers);
|
||||
} catch (HttpClientErrorException.Unauthorized e) {
|
||||
String www = e.getResponseHeaders() == null ? null
|
||||
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
|
||||
String token = obtainBearerToken(www);
|
||||
if (token == null) {
|
||||
log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www);
|
||||
return null;
|
||||
}
|
||||
headers.setBearerAuth(token);
|
||||
return digestCall(url, headers);
|
||||
}
|
||||
}
|
||||
|
||||
private String digestCall(String url, HttpHeaders headers) {
|
||||
ResponseEntity<Void> resp = http.exchange(
|
||||
url, HttpMethod.HEAD, new HttpEntity<>(headers), Void.class);
|
||||
return resp.getHeaders().getFirst("Docker-Content-Digest");
|
||||
}
|
||||
|
||||
/**
|
||||
* Suit le challenge {@code WWW-Authenticate: Bearer realm="...",service="...",scope="..."}
|
||||
* pour obtenir un jeton (anonyme — suffisant pour les images publiques).
|
||||
*/
|
||||
@SuppressWarnings("rawtypes")
|
||||
private String obtainBearerToken(String wwwAuth) {
|
||||
if (wwwAuth == null) return null;
|
||||
String prefix = "Bearer ";
|
||||
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
|
||||
Map<String, String> params = parseAuthParams(wwwAuth.substring(prefix.length()));
|
||||
String realm = params.get("realm");
|
||||
if (realm == null) return null;
|
||||
StringBuilder url = new StringBuilder(realm);
|
||||
boolean hasQuery = realm.contains("?");
|
||||
for (String key : new String[]{"service", "scope"}) {
|
||||
String v = params.get(key);
|
||||
if (v != null) {
|
||||
url.append(hasQuery ? '&' : '?')
|
||||
.append(key).append('=')
|
||||
.append(URLEncoder.encode(v, StandardCharsets.UTF_8));
|
||||
hasQuery = true;
|
||||
}
|
||||
}
|
||||
try {
|
||||
ResponseEntity<Map> resp = http.getForEntity(url.toString(), Map.class);
|
||||
Map<?, ?> body = resp.getBody();
|
||||
if (body == null) return null;
|
||||
Object t = body.get("token");
|
||||
if (t == null) t = body.get("access_token");
|
||||
return t == null ? null : t.toString();
|
||||
} catch (Exception e) {
|
||||
log.warn("Bearer token request failed: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parser minimaliste pour {@code key="value", key2="value2"}. */
|
||||
private static Map<String, String> parseAuthParams(String s) {
|
||||
Map<String, String> out = new HashMap<>();
|
||||
int i = 0;
|
||||
int n = s.length();
|
||||
while (i < n) {
|
||||
while (i < n && (s.charAt(i) == ',' || s.charAt(i) == ' ')) i++;
|
||||
int eq = s.indexOf('=', i);
|
||||
if (eq < 0) break;
|
||||
String key = s.substring(i, eq).trim();
|
||||
int valStart = eq + 1;
|
||||
String val;
|
||||
if (valStart < n && s.charAt(valStart) == '"') {
|
||||
int valEnd = s.indexOf('"', valStart + 1);
|
||||
if (valEnd < 0) break;
|
||||
val = s.substring(valStart + 1, valEnd);
|
||||
i = valEnd + 1;
|
||||
} else {
|
||||
int valEnd = s.indexOf(',', valStart);
|
||||
if (valEnd < 0) valEnd = n;
|
||||
val = s.substring(valStart, valEnd).trim();
|
||||
i = valEnd;
|
||||
}
|
||||
out.put(key, val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String normalizeRegistry(String value) {
|
||||
if (value == null || value.isBlank()) return "";
|
||||
String v = value.trim();
|
||||
if (!v.startsWith("http://") && !v.startsWith("https://")) {
|
||||
v = "https://" + v;
|
||||
}
|
||||
if (v.endsWith("/")) v = v.substring(0, v.length() - 1);
|
||||
return v;
|
||||
}
|
||||
|
||||
private static List<String> parseImages(String csv) {
|
||||
if (csv == null || csv.isBlank()) return List.of();
|
||||
List<String> out = new ArrayList<>();
|
||||
for (String part : csv.split(",")) {
|
||||
String p = part.trim();
|
||||
if (!p.isEmpty()) out.add(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Records de retour (sortis sous forme JSON par Jackson)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public record UpdateStatus(
|
||||
boolean enabled,
|
||||
boolean updateAvailable,
|
||||
List<ImageStatus> images,
|
||||
Instant checkedAt) {}
|
||||
|
||||
public record ImageStatus(
|
||||
String image,
|
||||
String localDigest,
|
||||
String remoteDigest,
|
||||
boolean updateAvailable) {}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ public class SecurityConfig {
|
||||
// Preflight CORS toujours libre (le browser n'envoie pas Authorization sur OPTIONS)
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.requestMatchers("/api/settings/**").hasRole("ADMIN")
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
.httpBasic(basic -> {});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.infrastructure.updates.UpdateCheckService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -18,13 +19,18 @@ import java.util.Map;
|
||||
public class ConfigController {
|
||||
|
||||
private final boolean demoMode;
|
||||
private final UpdateCheckService updates;
|
||||
|
||||
public ConfigController(@Value("${app.demo-mode:false}") boolean demoMode) {
|
||||
public ConfigController(@Value("${app.demo-mode:false}") boolean demoMode,
|
||||
UpdateCheckService updates) {
|
||||
this.demoMode = demoMode;
|
||||
this.updates = updates;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public Map<String, Object> getPublicConfig() {
|
||||
return Map.of("demoMode", demoMode);
|
||||
return Map.of(
|
||||
"demoMode", demoMode,
|
||||
"updateCheckEnabled", updates.isEnabled());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.infrastructure.updates.UpdateCheckService;
|
||||
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Endpoints admin pour la verification et le declenchement des mises a jour
|
||||
* des conteneurs LoreMind (core/brain/web).
|
||||
*
|
||||
* Protege par HTTP Basic via SecurityConfig (path /api/admin/**).
|
||||
* Si la feature n'est pas configuree (WATCHTOWER_TOKEN vide), check renvoie
|
||||
* {enabled:false} et apply repond 503.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/updates")
|
||||
public class UpdatesController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UpdatesController.class);
|
||||
|
||||
private final UpdateCheckService updates;
|
||||
private final boolean demoMode;
|
||||
|
||||
public UpdatesController(UpdateCheckService updates,
|
||||
@Value("${app.demo-mode:false}") boolean demoMode) {
|
||||
this.updates = updates;
|
||||
this.demoMode = demoMode;
|
||||
}
|
||||
|
||||
@GetMapping("/check")
|
||||
public UpdateStatus check() {
|
||||
guardDemoMode();
|
||||
return updates.check();
|
||||
}
|
||||
|
||||
@PostMapping("/apply")
|
||||
public ResponseEntity<Map<String, Object>> apply() {
|
||||
guardDemoMode();
|
||||
if (!updates.isEnabled()) {
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(Map.of("error", "Update apply not configured"));
|
||||
}
|
||||
try {
|
||||
updates.apply();
|
||||
return ResponseEntity.accepted()
|
||||
.body(Map.of("status", "triggered",
|
||||
"message", "Watchtower va telecharger et redemarrer les conteneurs."));
|
||||
} catch (Exception e) {
|
||||
log.error("Apply update failed", e);
|
||||
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(Map.of("error", "Watchtower unreachable: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* En mode demo, les instances ne doivent pas se mettre a jour ni meme
|
||||
* exposer leur statut (pas de surface d'attaque, et pas de redemarrage
|
||||
* intempestif d'une demo en cours). Cohérent avec SettingsController.
|
||||
*/
|
||||
private void guardDemoMode() {
|
||||
if (demoMode) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Updates disabled in demo mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,18 +87,14 @@ public class SceneMapper {
|
||||
private List<SceneBranchDTO> toBranchDTOs(List<SceneBranch> branches) {
|
||||
if (branches == null) return new ArrayList<>();
|
||||
return branches.stream()
|
||||
.map(b -> new SceneBranchDTO(b.getLabel(), b.getTargetSceneId(), b.getCondition()))
|
||||
.map(b -> new SceneBranchDTO(b.label(), b.targetSceneId(), b.condition()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private List<SceneBranch> toBranchDomain(List<SceneBranchDTO> dtos) {
|
||||
if (dtos == null) return new ArrayList<>();
|
||||
return dtos.stream()
|
||||
.map(d -> SceneBranch.builder()
|
||||
.label(d.getLabel())
|
||||
.targetSceneId(d.getTargetSceneId())
|
||||
.condition(d.getCondition())
|
||||
.build())
|
||||
.map(d -> new SceneBranch(d.getLabel(), d.getTargetSceneId(), d.getCondition()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,3 +54,11 @@ spring.servlet.multipart.max-request-size=10MB
|
||||
# Mode demo : masque Settings/Export cote front et bloque les PUT /api/settings
|
||||
# cote serveur. Activer via DEMO_MODE=true sur les deploiements publics.
|
||||
app.demo-mode=${DEMO_MODE:false}
|
||||
|
||||
# Detection des mises a jour des conteneurs Docker (registry HTTP API + Watchtower).
|
||||
# Feature desactivee si UPDATE_CHECK_WATCHTOWER_TOKEN est vide.
|
||||
update-check.registry=${UPDATE_CHECK_REGISTRY:}
|
||||
update-check.images=${UPDATE_CHECK_IMAGES:}
|
||||
update-check.tag=${UPDATE_CHECK_TAG:latest}
|
||||
update-check.watchtower-url=${WATCHTOWER_URL:http://watchtower:8080}
|
||||
update-check.watchtower-token=${WATCHTOWER_TOKEN:}
|
||||
|
||||
@@ -178,10 +178,7 @@ public class SceneServiceTest {
|
||||
@Test
|
||||
void testUpdateScene_WithValidBranches() {
|
||||
// Arrange
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.targetSceneId("scene-2")
|
||||
.label("Go to scene 2")
|
||||
.build();
|
||||
SceneBranch branch = SceneBranch.of("Go to scene 2", "scene-2");
|
||||
Scene updatedScene = Scene.builder()
|
||||
.name("Updated Scene")
|
||||
.branches(List.of(branch))
|
||||
@@ -203,10 +200,7 @@ public class SceneServiceTest {
|
||||
@Test
|
||||
void testUpdateScene_WithBranchToSelf() {
|
||||
// Arrange
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.targetSceneId("scene-1")
|
||||
.label("Self-reference")
|
||||
.build();
|
||||
SceneBranch branch = SceneBranch.of("Self-reference", "scene-1");
|
||||
Scene updatedScene = Scene.builder()
|
||||
.name("Updated Scene")
|
||||
.branches(List.of(branch))
|
||||
@@ -228,10 +222,7 @@ public class SceneServiceTest {
|
||||
@Test
|
||||
void testUpdateScene_WithBranchToDifferentChapter() {
|
||||
// Arrange
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.targetSceneId("scene-other-chapter")
|
||||
.label("Go to other chapter")
|
||||
.build();
|
||||
SceneBranch branch = SceneBranch.of("Go to other chapter", "scene-other-chapter");
|
||||
Scene updatedScene = Scene.builder()
|
||||
.name("Updated Scene")
|
||||
.branches(List.of(branch))
|
||||
@@ -253,10 +244,7 @@ public class SceneServiceTest {
|
||||
@Test
|
||||
void testUpdateScene_WithBranchNullTarget() {
|
||||
// Arrange
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.targetSceneId(null)
|
||||
.label("Null target")
|
||||
.build();
|
||||
SceneBranch branch = SceneBranch.of("Null target", null);
|
||||
Scene updatedScene = Scene.builder()
|
||||
.name("Updated Scene")
|
||||
.branches(List.of(branch))
|
||||
@@ -277,10 +265,7 @@ public class SceneServiceTest {
|
||||
@Test
|
||||
void testUpdateScene_WithBranchBlankTarget() {
|
||||
// Arrange
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.targetSceneId(" ")
|
||||
.label("Blank target")
|
||||
.build();
|
||||
SceneBranch branch = SceneBranch.of("Blank target", " ");
|
||||
Scene updatedScene = Scene.builder()
|
||||
.name("Updated Scene")
|
||||
.branches(List.of(branch))
|
||||
|
||||
@@ -74,9 +74,9 @@ public class CampaignStructuralContextBuilderTest {
|
||||
|
||||
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||
|
||||
assertEquals("Les Terres Brisées", ctx.getCampaignName());
|
||||
assertEquals("Campagne dark fantasy", ctx.getCampaignDescription());
|
||||
assertTrue(ctx.getArcs().isEmpty());
|
||||
assertEquals("Les Terres Brisées", ctx.campaignName());
|
||||
assertEquals("Campagne dark fantasy", ctx.campaignDescription());
|
||||
assertTrue(ctx.arcs().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -100,19 +100,19 @@ public class CampaignStructuralContextBuilderTest {
|
||||
|
||||
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||
|
||||
assertEquals(2, ctx.getArcs().size());
|
||||
assertEquals("Arc A", ctx.getArcs().get(0).getName());
|
||||
assertEquals("Arc B", ctx.getArcs().get(1).getName());
|
||||
assertEquals(2, ctx.arcs().size());
|
||||
assertEquals("Arc A", ctx.arcs().get(0).name());
|
||||
assertEquals("Arc B", ctx.arcs().get(1).name());
|
||||
|
||||
// Chapitres tries : ch2 (order 1) avant ch1 (order 2)
|
||||
assertEquals(2, ctx.getArcs().get(0).getChapters().size());
|
||||
assertEquals("Ch B", ctx.getArcs().get(0).getChapters().get(0).getName());
|
||||
assertEquals("Ch A", ctx.getArcs().get(0).getChapters().get(1).getName());
|
||||
assertEquals(2, ctx.arcs().get(0).chapters().size());
|
||||
assertEquals("Ch B", ctx.arcs().get(0).chapters().get(0).name());
|
||||
assertEquals("Ch A", ctx.arcs().get(0).chapters().get(1).name());
|
||||
|
||||
// Scenes dans ch-1 : s2 (order 1) avant s1 (order 2)
|
||||
var chADto = ctx.getArcs().get(0).getChapters().get(1);
|
||||
assertEquals("Scene B", chADto.getScenes().get(0).getName());
|
||||
assertEquals("Scene A", chADto.getScenes().get(1).getName());
|
||||
var chADto = ctx.arcs().get(0).chapters().get(1);
|
||||
assertEquals("Scene B", chADto.scenes().get(0).name());
|
||||
assertEquals("Scene A", chADto.scenes().get(1).name());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -120,15 +120,8 @@ public class CampaignStructuralContextBuilderTest {
|
||||
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1).build();
|
||||
Chapter ch = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch").description("").order(1).build();
|
||||
|
||||
SceneBranch validBranch = SceneBranch.builder()
|
||||
.label("Si les joueurs fuient")
|
||||
.targetSceneId("s-2")
|
||||
.condition("en cas de combat perdu")
|
||||
.build();
|
||||
SceneBranch danglingBranch = SceneBranch.builder()
|
||||
.label("Vers l'inconnu")
|
||||
.targetSceneId("s-inconnu")
|
||||
.build();
|
||||
SceneBranch validBranch = new SceneBranch("Si les joueurs fuient", "s-2", "en cas de combat perdu");
|
||||
SceneBranch danglingBranch = SceneBranch.of("Vers l'inconnu", "s-inconnu");
|
||||
|
||||
Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Entrée").description("")
|
||||
.order(1)
|
||||
@@ -143,12 +136,12 @@ public class CampaignStructuralContextBuilderTest {
|
||||
|
||||
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||
|
||||
var scene1Summary = ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0);
|
||||
assertEquals(2, scene1Summary.getBranches().size());
|
||||
assertEquals("Fuite", scene1Summary.getBranches().get(0).getTargetSceneName());
|
||||
assertEquals("en cas de combat perdu", scene1Summary.getBranches().get(0).getCondition());
|
||||
var scene1Summary = ctx.arcs().get(0).chapters().get(0).scenes().get(0);
|
||||
assertEquals(2, scene1Summary.branches().size());
|
||||
assertEquals("Fuite", scene1Summary.branches().get(0).targetSceneName());
|
||||
assertEquals("en cas de combat perdu", scene1Summary.branches().get(0).condition());
|
||||
// ID inconnu → libellé de fallback
|
||||
assertEquals("(scène inconnue)", scene1Summary.getBranches().get(1).getTargetSceneName());
|
||||
assertEquals("(scène inconnue)", scene1Summary.branches().get(1).targetSceneName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -170,9 +163,9 @@ public class CampaignStructuralContextBuilderTest {
|
||||
|
||||
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||
|
||||
assertEquals(2, ctx.getArcs().get(0).getIllustrationCount());
|
||||
assertEquals(0, ctx.getArcs().get(0).getChapters().get(0).getIllustrationCount());
|
||||
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getIllustrationCount());
|
||||
assertTrue(ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().isEmpty());
|
||||
assertEquals(2, ctx.arcs().get(0).illustrationCount());
|
||||
assertEquals(0, ctx.arcs().get(0).chapters().get(0).illustrationCount());
|
||||
assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().get(0).illustrationCount());
|
||||
assertTrue(ctx.arcs().get(0).chapters().get(0).scenes().get(0).branches().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,13 +89,13 @@ public class GeneratePageValuesUseCaseTest {
|
||||
verify(aiProvider).generatePage(captor.capture());
|
||||
GenerationContext ctx = captor.getValue();
|
||||
|
||||
assertEquals("Aetheria", ctx.getLoreName());
|
||||
assertEquals("monde aérien", ctx.getLoreDescription());
|
||||
assertEquals("PNJ", ctx.getFolderName());
|
||||
assertEquals("Personnage", ctx.getTemplateName());
|
||||
assertEquals("Alice", ctx.getPageTitle());
|
||||
assertEquals("Aetheria", ctx.loreName());
|
||||
assertEquals("monde aérien", ctx.loreDescription());
|
||||
assertEquals("PNJ", ctx.folderName());
|
||||
assertEquals("Personnage", ctx.templateName());
|
||||
assertEquals("Alice", ctx.pageTitle());
|
||||
// Seuls les champs TEXT doivent etre envoyes (pas "Portrait" IMAGE).
|
||||
assertEquals(List.of("Histoire", "Apparence"), ctx.getTemplateFields());
|
||||
assertEquals(List.of("Histoire", "Apparence"), ctx.templateFields());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -71,10 +71,10 @@ public class LoreStructuralContextBuilderTest {
|
||||
|
||||
LoreStructuralContext ctx = builder.build("lore-1");
|
||||
|
||||
assertEquals("Aetheria", ctx.getLoreName());
|
||||
assertEquals("Monde aérien", ctx.getLoreDescription());
|
||||
assertTrue(ctx.getFolders().isEmpty());
|
||||
assertTrue(ctx.getTags().isEmpty());
|
||||
assertEquals("Aetheria", ctx.loreName());
|
||||
assertEquals("Monde aérien", ctx.loreDescription());
|
||||
assertTrue(ctx.folders().isEmpty());
|
||||
assertTrue(ctx.tags().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -110,32 +110,32 @@ public class LoreStructuralContextBuilderTest {
|
||||
|
||||
LoreStructuralContext ctx = builder.build("lore-1");
|
||||
|
||||
assertEquals(2, ctx.getFolders().size());
|
||||
assertTrue(ctx.getFolders().containsKey("PNJ"));
|
||||
assertTrue(ctx.getFolders().containsKey("Lieux"));
|
||||
assertEquals(2, ctx.folders().size());
|
||||
assertTrue(ctx.folders().containsKey("PNJ"));
|
||||
assertTrue(ctx.folders().containsKey("Lieux"));
|
||||
|
||||
var pnjPages = ctx.getFolders().get("PNJ");
|
||||
var pnjPages = ctx.folders().get("PNJ");
|
||||
assertEquals(1, pnjPages.size());
|
||||
var aliceSummary = pnjPages.get(0);
|
||||
assertEquals("Alice", aliceSummary.getTitle());
|
||||
assertEquals("Personnage", aliceSummary.getTemplateName());
|
||||
assertEquals("Alice", aliceSummary.title());
|
||||
assertEquals("Personnage", aliceSummary.templateName());
|
||||
// Blank/null filtrés
|
||||
assertEquals(1, aliceSummary.getValues().size());
|
||||
assertEquals("Il était une fois...", aliceSummary.getValues().get("Histoire"));
|
||||
assertEquals(List.of("hero", "magic"), aliceSummary.getTags());
|
||||
assertEquals(1, aliceSummary.values().size());
|
||||
assertEquals("Il était une fois...", aliceSummary.values().get("Histoire"));
|
||||
assertEquals(List.of("hero", "magic"), aliceSummary.tags());
|
||||
// p-2 resolved into title, p-ghost dropped silently
|
||||
assertEquals(List.of("La Forêt"), aliceSummary.getRelatedPageTitles());
|
||||
assertEquals(List.of("La Forêt"), aliceSummary.relatedPageTitles());
|
||||
|
||||
var forestSummary = ctx.getFolders().get("Lieux").get(0);
|
||||
var forestSummary = ctx.folders().get("Lieux").get(0);
|
||||
// Template introuvable → "?"
|
||||
assertEquals("?", forestSummary.getTemplateName());
|
||||
assertTrue(forestSummary.getValues().isEmpty());
|
||||
assertTrue(forestSummary.getRelatedPageTitles().isEmpty());
|
||||
assertEquals("?", forestSummary.templateName());
|
||||
assertTrue(forestSummary.values().isEmpty());
|
||||
assertTrue(forestSummary.relatedPageTitles().isEmpty());
|
||||
|
||||
// Tags uniques entre les 2 pages
|
||||
assertEquals(2, ctx.getTags().size());
|
||||
assertTrue(ctx.getTags().contains("hero"));
|
||||
assertTrue(ctx.getTags().contains("magic"));
|
||||
assertEquals(2, ctx.tags().size());
|
||||
assertTrue(ctx.tags().contains("hero"));
|
||||
assertTrue(ctx.tags().contains("magic"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -160,7 +160,7 @@ public class LoreStructuralContextBuilderTest {
|
||||
|
||||
LoreStructuralContext ctx = builder.build("lore-1");
|
||||
|
||||
String truncated = ctx.getFolders().get("PNJ").get(0).getValues().get("Histoire");
|
||||
String truncated = ctx.folders().get("PNJ").get(0).values().get("Histoire");
|
||||
assertNotNull(truncated);
|
||||
assertEquals(500 + 1, truncated.length()); // 500 + ellipse
|
||||
assertTrue(truncated.endsWith("…"));
|
||||
@@ -185,9 +185,9 @@ public class LoreStructuralContextBuilderTest {
|
||||
|
||||
LoreStructuralContext ctx = builder.build("lore-1");
|
||||
|
||||
var summary = ctx.getFolders().get("PNJ").get(0);
|
||||
assertTrue(summary.getValues().isEmpty());
|
||||
assertTrue(summary.getTags().isEmpty());
|
||||
assertTrue(summary.getRelatedPageTitles().isEmpty());
|
||||
var summary = ctx.folders().get("PNJ").get(0);
|
||||
assertTrue(summary.values().isEmpty());
|
||||
assertTrue(summary.tags().isEmpty());
|
||||
assertTrue(summary.relatedPageTitles().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,14 +44,14 @@ public class NarrativeEntityContextBuilderTest {
|
||||
|
||||
NarrativeEntityContext ctx = builder.build("arc", "arc-1");
|
||||
|
||||
assertEquals("arc", ctx.getEntityType());
|
||||
assertEquals("L'arc sombre", ctx.getTitle());
|
||||
assertEquals("synopsis", ctx.getFields().get("description (synopsis)"));
|
||||
assertEquals("trahison", ctx.getFields().get("themes"));
|
||||
assertEquals("vie ou mort", ctx.getFields().get("stakes"));
|
||||
assertEquals("pouvoir", ctx.getFields().get("rewards"));
|
||||
assertEquals("le roi meurt", ctx.getFields().get("resolution"));
|
||||
assertEquals("secret", ctx.getFields().get("gmNotes"));
|
||||
assertEquals("arc", ctx.entityType());
|
||||
assertEquals("L'arc sombre", ctx.title());
|
||||
assertEquals("synopsis", ctx.fields().get("description (synopsis)"));
|
||||
assertEquals("trahison", ctx.fields().get("themes"));
|
||||
assertEquals("vie ou mort", ctx.fields().get("stakes"));
|
||||
assertEquals("pouvoir", ctx.fields().get("rewards"));
|
||||
assertEquals("le roi meurt", ctx.fields().get("resolution"));
|
||||
assertEquals("secret", ctx.fields().get("gmNotes"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -64,12 +64,12 @@ public class NarrativeEntityContextBuilderTest {
|
||||
|
||||
NarrativeEntityContext ctx = builder.build("chapter", "ch-1");
|
||||
|
||||
assertEquals("chapter", ctx.getEntityType());
|
||||
assertEquals("Chapitre 1", ctx.getTitle());
|
||||
assertEquals("", ctx.getFields().get("description (synopsis)"));
|
||||
assertEquals("", ctx.getFields().get("playerObjectives"));
|
||||
assertEquals("haut", ctx.getFields().get("narrativeStakes"));
|
||||
assertEquals("", ctx.getFields().get("gmNotes"));
|
||||
assertEquals("chapter", ctx.entityType());
|
||||
assertEquals("Chapitre 1", ctx.title());
|
||||
assertEquals("", ctx.fields().get("description (synopsis)"));
|
||||
assertEquals("", ctx.fields().get("playerObjectives"));
|
||||
assertEquals("haut", ctx.fields().get("narrativeStakes"));
|
||||
assertEquals("", ctx.fields().get("gmNotes"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -85,17 +85,17 @@ public class NarrativeEntityContextBuilderTest {
|
||||
|
||||
NarrativeEntityContext ctx = builder.build("scene", "s-1");
|
||||
|
||||
assertEquals("scene", ctx.getEntityType());
|
||||
assertEquals("L'auberge", ctx.getTitle());
|
||||
assertEquals("lieu calme", ctx.getFields().get("description"));
|
||||
assertEquals("Taverne", ctx.getFields().get("location"));
|
||||
assertEquals("Soir", ctx.getFields().get("timing"));
|
||||
assertEquals("tendue", ctx.getFields().get("atmosphere"));
|
||||
assertEquals("Vous entrez...", ctx.getFields().get("playerNarration"));
|
||||
assertEquals("option A...", ctx.getFields().get("choicesConsequences"));
|
||||
assertEquals("moyen", ctx.getFields().get("combatDifficulty"));
|
||||
assertEquals("3 bandits", ctx.getFields().get("enemies"));
|
||||
assertEquals("trésor caché", ctx.getFields().get("gmSecretNotes"));
|
||||
assertEquals("scene", ctx.entityType());
|
||||
assertEquals("L'auberge", ctx.title());
|
||||
assertEquals("lieu calme", ctx.fields().get("description"));
|
||||
assertEquals("Taverne", ctx.fields().get("location"));
|
||||
assertEquals("Soir", ctx.fields().get("timing"));
|
||||
assertEquals("tendue", ctx.fields().get("atmosphere"));
|
||||
assertEquals("Vous entrez...", ctx.fields().get("playerNarration"));
|
||||
assertEquals("option A...", ctx.fields().get("choicesConsequences"));
|
||||
assertEquals("moyen", ctx.fields().get("combatDifficulty"));
|
||||
assertEquals("3 bandits", ctx.fields().get("enemies"));
|
||||
assertEquals("trésor caché", ctx.fields().get("gmSecretNotes"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -104,7 +104,7 @@ public class NarrativeEntityContextBuilderTest {
|
||||
when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc));
|
||||
|
||||
NarrativeEntityContext ctx = builder.build(" ARC ", "arc-1");
|
||||
assertEquals("arc", ctx.getEntityType());
|
||||
assertEquals("arc", ctx.entityType());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -55,9 +55,7 @@ public class StreamChatForCampaignUseCaseTest {
|
||||
@SuppressWarnings("unchecked")
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
campaignCtx = CampaignStructuralContext.builder()
|
||||
.campaignName("X").campaignDescription("d")
|
||||
.build();
|
||||
campaignCtx = new CampaignStructuralContext("X", "d", List.of(), List.of());
|
||||
messages = List.of();
|
||||
onUsage = mock(Consumer.class);
|
||||
onToken = mock(Consumer.class);
|
||||
@@ -85,10 +83,10 @@ public class StreamChatForCampaignUseCaseTest {
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
|
||||
ChatRequest req = captor.getValue();
|
||||
assertSame(campaignCtx, req.getCampaignContext());
|
||||
assertNull(req.getLoreContext());
|
||||
assertNull(req.getNarrativeEntity());
|
||||
assertNull(req.getPageContext());
|
||||
assertSame(campaignCtx, req.campaignContext());
|
||||
assertNull(req.loreContext());
|
||||
assertNull(req.narrativeEntity());
|
||||
assertNull(req.pageContext());
|
||||
verifyNoInteractions(loreContextBuilder);
|
||||
verifyNoInteractions(narrativeEntityContextBuilder);
|
||||
}
|
||||
@@ -96,8 +94,8 @@ public class StreamChatForCampaignUseCaseTest {
|
||||
@Test
|
||||
void testExecute_LinkedCampaign_LoadsLoreContext() {
|
||||
Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-1").build();
|
||||
LoreStructuralContext loreCtx = LoreStructuralContext.builder()
|
||||
.loreName("L").loreDescription("d").folders(Collections.emptyMap()).build();
|
||||
LoreStructuralContext loreCtx = new LoreStructuralContext(
|
||||
"L", "d", Collections.emptyMap(), List.of());
|
||||
|
||||
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked));
|
||||
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
|
||||
@@ -107,7 +105,7 @@ public class StreamChatForCampaignUseCaseTest {
|
||||
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||
assertSame(loreCtx, captor.getValue().getLoreContext());
|
||||
assertSame(loreCtx, captor.getValue().loreContext());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -122,15 +120,14 @@ public class StreamChatForCampaignUseCaseTest {
|
||||
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||
assertNull(captor.getValue().getLoreContext());
|
||||
assertNull(captor.getValue().loreContext());
|
||||
// La requete doit tout de meme partir (pas d'exception).
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExecute_WithEntityFocus_BuildsNarrativeEntity() {
|
||||
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
|
||||
NarrativeEntityContext entity = NarrativeEntityContext.builder()
|
||||
.entityType("scene").title("L'auberge").fields(Map.of()).build();
|
||||
NarrativeEntityContext entity = new NarrativeEntityContext("scene", "L'auberge", Map.of());
|
||||
|
||||
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
|
||||
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
|
||||
@@ -140,7 +137,7 @@ public class StreamChatForCampaignUseCaseTest {
|
||||
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||
assertSame(entity, captor.getValue().getNarrativeEntity());
|
||||
assertSame(entity, captor.getValue().narrativeEntity());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -153,7 +150,7 @@ public class StreamChatForCampaignUseCaseTest {
|
||||
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||
assertNull(captor.getValue().getNarrativeEntity());
|
||||
assertNull(captor.getValue().narrativeEntity());
|
||||
verifyNoInteractions(narrativeEntityContextBuilder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +55,7 @@ public class StreamChatForLoreUseCaseTest {
|
||||
@SuppressWarnings("unchecked")
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
loreCtx = LoreStructuralContext.builder()
|
||||
.loreName("Aetheria").loreDescription("d")
|
||||
.folders(Collections.emptyMap())
|
||||
.build();
|
||||
loreCtx = new LoreStructuralContext("Aetheria", "d", Collections.emptyMap(), List.of());
|
||||
messages = List.of();
|
||||
onUsage = mock(Consumer.class);
|
||||
onToken = mock(Consumer.class);
|
||||
@@ -75,9 +72,9 @@ public class StreamChatForLoreUseCaseTest {
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
|
||||
ChatRequest req = captor.getValue();
|
||||
assertSame(loreCtx, req.getLoreContext());
|
||||
assertNull(req.getPageContext());
|
||||
assertNull(req.getCampaignContext());
|
||||
assertSame(loreCtx, req.loreContext());
|
||||
assertNull(req.pageContext());
|
||||
assertNull(req.campaignContext());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -88,7 +85,7 @@ public class StreamChatForLoreUseCaseTest {
|
||||
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||
assertNull(captor.getValue().getPageContext());
|
||||
assertNull(captor.getValue().pageContext());
|
||||
verifyNoInteractions(pageRepository);
|
||||
}
|
||||
|
||||
@@ -116,12 +113,12 @@ public class StreamChatForLoreUseCaseTest {
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||
ChatRequest req = captor.getValue();
|
||||
assertNotNull(req.getPageContext());
|
||||
assertEquals("Alice", req.getPageContext().getTitle());
|
||||
assertEquals("Personnage", req.getPageContext().getTemplateName());
|
||||
assertNotNull(req.pageContext());
|
||||
assertEquals("Alice", req.pageContext().title());
|
||||
assertEquals("Personnage", req.pageContext().templateName());
|
||||
// Seuls les champs TEXT exposes
|
||||
assertEquals(List.of("Histoire"), req.getPageContext().getTemplateFields());
|
||||
assertEquals(values, req.getPageContext().getValues());
|
||||
assertEquals(List.of("Histoire"), req.pageContext().templateFields());
|
||||
assertEquals(values, req.pageContext().values());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -137,12 +134,12 @@ public class StreamChatForLoreUseCaseTest {
|
||||
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||
var pageCtx = captor.getValue().getPageContext();
|
||||
var pageCtx = captor.getValue().pageContext();
|
||||
assertNotNull(pageCtx);
|
||||
assertEquals("Orphan", pageCtx.getTitle());
|
||||
assertEquals("?", pageCtx.getTemplateName());
|
||||
assertTrue(pageCtx.getTemplateFields().isEmpty());
|
||||
assertTrue(pageCtx.getValues().isEmpty());
|
||||
assertEquals("Orphan", pageCtx.title());
|
||||
assertEquals("?", pageCtx.templateName());
|
||||
assertTrue(pageCtx.templateFields().isEmpty());
|
||||
assertTrue(pageCtx.values().isEmpty());
|
||||
verifyNoInteractions(templateRepository);
|
||||
}
|
||||
|
||||
@@ -160,9 +157,9 @@ public class StreamChatForLoreUseCaseTest {
|
||||
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||
var pageCtx = captor.getValue().getPageContext();
|
||||
assertEquals("?", pageCtx.getTemplateName());
|
||||
assertTrue(pageCtx.getTemplateFields().isEmpty());
|
||||
var pageCtx = captor.getValue().pageContext();
|
||||
assertEquals("?", pageCtx.templateName());
|
||||
assertTrue(pageCtx.templateFields().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -9,48 +9,36 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
/**
|
||||
* Tests unitaires pour SceneBranch (Value Object).
|
||||
* Verifie :
|
||||
* - l'immuabilite (pas de setters : seul le builder permet la construction),
|
||||
* - l'egalite structurelle generee par @Value (equals/hashCode sur tous les
|
||||
* - l'immuabilite (record : aucun setter, constructeur canonique uniquement),
|
||||
* - l'egalite structurelle generee par record (equals/hashCode sur tous les
|
||||
* champs) — deux branches aux memes champs sont strictement egales,
|
||||
* - le support du champ optionnel {@code condition}.
|
||||
*/
|
||||
class SceneBranchTest {
|
||||
|
||||
@Test
|
||||
void builder_exposesAllFields() {
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.label("Si les joueurs attaquent le garde")
|
||||
.targetSceneId("sc-combat")
|
||||
.condition("initiative > 15")
|
||||
.build();
|
||||
void constructor_exposesAllFields() {
|
||||
SceneBranch branch = new SceneBranch(
|
||||
"Si les joueurs attaquent le garde",
|
||||
"sc-combat",
|
||||
"initiative > 15");
|
||||
|
||||
assertEquals("Si les joueurs attaquent le garde", branch.getLabel());
|
||||
assertEquals("sc-combat", branch.getTargetSceneId());
|
||||
assertEquals("initiative > 15", branch.getCondition());
|
||||
assertEquals("Si les joueurs attaquent le garde", branch.label());
|
||||
assertEquals("sc-combat", branch.targetSceneId());
|
||||
assertEquals("initiative > 15", branch.condition());
|
||||
}
|
||||
|
||||
@Test
|
||||
void condition_isOptional() {
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.label("sortie par la porte")
|
||||
.targetSceneId("sc-corridor")
|
||||
.build();
|
||||
SceneBranch branch = SceneBranch.of("sortie par la porte", "sc-corridor");
|
||||
|
||||
assertNull(branch.getCondition());
|
||||
assertNull(branch.condition());
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoBranches_withSameFields_areEqual() {
|
||||
SceneBranch a = SceneBranch.builder()
|
||||
.label("fuite")
|
||||
.targetSceneId("sc-2")
|
||||
.condition(null)
|
||||
.build();
|
||||
SceneBranch b = SceneBranch.builder()
|
||||
.label("fuite")
|
||||
.targetSceneId("sc-2")
|
||||
.condition(null)
|
||||
.build();
|
||||
SceneBranch a = new SceneBranch("fuite", "sc-2", null);
|
||||
SceneBranch b = new SceneBranch("fuite", "sc-2", null);
|
||||
|
||||
assertEquals(a, b);
|
||||
assertEquals(a.hashCode(), b.hashCode());
|
||||
@@ -58,16 +46,16 @@ class SceneBranchTest {
|
||||
|
||||
@Test
|
||||
void twoBranches_differingOnTargetSceneId_areNotEqual() {
|
||||
SceneBranch a = SceneBranch.builder().label("X").targetSceneId("sc-1").build();
|
||||
SceneBranch b = SceneBranch.builder().label("X").targetSceneId("sc-2").build();
|
||||
SceneBranch a = SceneBranch.of("X", "sc-1");
|
||||
SceneBranch b = SceneBranch.of("X", "sc-2");
|
||||
|
||||
assertNotEquals(a, b);
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoBranches_differingOnCondition_areNotEqual() {
|
||||
SceneBranch a = SceneBranch.builder().label("X").targetSceneId("sc-1").condition("A").build();
|
||||
SceneBranch b = SceneBranch.builder().label("X").targetSceneId("sc-1").condition("B").build();
|
||||
SceneBranch a = new SceneBranch("X", "sc-1", "A");
|
||||
SceneBranch b = new SceneBranch("X", "sc-1", "B");
|
||||
|
||||
assertNotEquals(a, b);
|
||||
}
|
||||
|
||||
@@ -60,15 +60,15 @@ class SceneTest {
|
||||
|
||||
@Test
|
||||
void builder_preservesBranches_whenProvided() {
|
||||
SceneBranch b1 = SceneBranch.builder().label("fuite").targetSceneId("sc-2").build();
|
||||
SceneBranch b2 = SceneBranch.builder().label("combat").targetSceneId("sc-3").build();
|
||||
SceneBranch b1 = SceneBranch.of("fuite", "sc-2");
|
||||
SceneBranch b2 = SceneBranch.of("combat", "sc-3");
|
||||
|
||||
Scene scene = Scene.builder()
|
||||
.branches(List.of(b1, b2))
|
||||
.build();
|
||||
|
||||
assertEquals(2, scene.getBranches().size());
|
||||
assertEquals("fuite", scene.getBranches().get(0).getLabel());
|
||||
assertEquals("sc-3", scene.getBranches().get(1).getTargetSceneId());
|
||||
assertEquals("fuite", scene.getBranches().get(0).label());
|
||||
assertEquals("sc-3", scene.getBranches().get(1).targetSceneId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,108 +6,97 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSu
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour CampaignStructuralContext et ses types imbriques.
|
||||
* Focus sur les annotations @Singular (chapters/scenes/branches/arcs) qui
|
||||
* permettent une construction incrementale du graphe narratif.
|
||||
* Records purs : aucune dependance technique.
|
||||
*/
|
||||
class CampaignStructuralContextTest {
|
||||
|
||||
@Test
|
||||
void builder_constructsFullNarrativeTree() {
|
||||
BranchHint branch = BranchHint.builder()
|
||||
.label("si les PJ fuient")
|
||||
.targetSceneName("La poursuite")
|
||||
.condition("PJ < moitie des HP")
|
||||
.build();
|
||||
void constructor_buildsFullNarrativeTree() {
|
||||
BranchHint branch = new BranchHint("si les PJ fuient", "La poursuite", "PJ < moitie des HP");
|
||||
|
||||
SceneSummary scene = SceneSummary.builder()
|
||||
.name("L'auberge")
|
||||
.description("Rencontre tendue avec le tavernier")
|
||||
.illustrationCount(2)
|
||||
.branch(branch)
|
||||
.build();
|
||||
SceneSummary scene = new SceneSummary(
|
||||
"L'auberge",
|
||||
"Rencontre tendue avec le tavernier",
|
||||
2,
|
||||
List.of(branch));
|
||||
|
||||
ChapterSummary chapter = ChapterSummary.builder()
|
||||
.name("L'arrivee")
|
||||
.description("Les PJ decouvrent la ville")
|
||||
.scene(scene)
|
||||
.build();
|
||||
ChapterSummary chapter = new ChapterSummary(
|
||||
"L'arrivee",
|
||||
"Les PJ decouvrent la ville",
|
||||
0,
|
||||
List.of(scene));
|
||||
|
||||
ArcSummary arc = ArcSummary.builder()
|
||||
.name("Acte I")
|
||||
.description("Mise en place")
|
||||
.illustrationCount(1)
|
||||
.chapter(chapter)
|
||||
.build();
|
||||
ArcSummary arc = new ArcSummary(
|
||||
"Acte I",
|
||||
"Mise en place",
|
||||
1,
|
||||
List.of(chapter));
|
||||
|
||||
CampaignStructuralContext ctx = CampaignStructuralContext.builder()
|
||||
.campaignName("Les Ombres")
|
||||
.campaignDescription("Une campagne dark fantasy")
|
||||
.arc(arc)
|
||||
.build();
|
||||
CampaignStructuralContext ctx = new CampaignStructuralContext(
|
||||
"Les Ombres",
|
||||
"Une campagne dark fantasy",
|
||||
List.of(arc),
|
||||
List.of());
|
||||
|
||||
assertEquals("Les Ombres", ctx.getCampaignName());
|
||||
assertEquals(1, ctx.getArcs().size());
|
||||
assertEquals(1, ctx.getArcs().get(0).getChapters().size());
|
||||
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().size());
|
||||
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().size());
|
||||
assertEquals("Les Ombres", ctx.campaignName());
|
||||
assertEquals(1, ctx.arcs().size());
|
||||
assertEquals(1, ctx.arcs().get(0).chapters().size());
|
||||
assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().size());
|
||||
assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().get(0).branches().size());
|
||||
}
|
||||
|
||||
// --- BranchHint ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void branchHint_preservesAllFields() {
|
||||
BranchHint b = BranchHint.builder()
|
||||
.label("combat")
|
||||
.targetSceneName("La confrontation")
|
||||
.condition("initiative > 15")
|
||||
.build();
|
||||
BranchHint b = new BranchHint("combat", "La confrontation", "initiative > 15");
|
||||
|
||||
assertEquals("combat", b.getLabel());
|
||||
assertEquals("La confrontation", b.getTargetSceneName());
|
||||
assertEquals("initiative > 15", b.getCondition());
|
||||
assertEquals("combat", b.label());
|
||||
assertEquals("La confrontation", b.targetSceneName());
|
||||
assertEquals("initiative > 15", b.condition());
|
||||
}
|
||||
|
||||
@Test
|
||||
void branchHint_conditionIsOptional() {
|
||||
BranchHint b = BranchHint.builder()
|
||||
.label("suite normale")
|
||||
.targetSceneName("Scene 2")
|
||||
.build();
|
||||
BranchHint b = new BranchHint("suite normale", "Scene 2", null);
|
||||
|
||||
assertNull(b.getCondition());
|
||||
assertNull(b.condition());
|
||||
}
|
||||
|
||||
// --- illustrationCount --------------------------------------------------
|
||||
|
||||
@Test
|
||||
void illustrationCount_defaultsToZero_onAllSummaryTypes() {
|
||||
ArcSummary arc = ArcSummary.builder().name("X").build();
|
||||
ChapterSummary chapter = ChapterSummary.builder().name("X").build();
|
||||
SceneSummary scene = SceneSummary.builder().name("X").build();
|
||||
ArcSummary arc = new ArcSummary("X", null, 0, List.of());
|
||||
ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of());
|
||||
SceneSummary scene = new SceneSummary("X", null, 0, List.of());
|
||||
|
||||
assertEquals(0, arc.getIllustrationCount());
|
||||
assertEquals(0, chapter.getIllustrationCount());
|
||||
assertEquals(0, scene.getIllustrationCount());
|
||||
assertEquals(0, arc.illustrationCount());
|
||||
assertEquals(0, chapter.illustrationCount());
|
||||
assertEquals(0, scene.illustrationCount());
|
||||
}
|
||||
|
||||
// --- @Singular : accumulation incrementale -----------------------------
|
||||
// --- Construction incrementale (chapitres multiples) -------------------
|
||||
|
||||
@Test
|
||||
void singular_accumulatesMultipleCalls() {
|
||||
ArcSummary arc = ArcSummary.builder()
|
||||
.name("Acte I")
|
||||
.chapter(ChapterSummary.builder().name("Ch1").build())
|
||||
.chapter(ChapterSummary.builder().name("Ch2").build())
|
||||
.chapter(ChapterSummary.builder().name("Ch3").build())
|
||||
.build();
|
||||
void multipleChapters_arePreserved() {
|
||||
ArcSummary arc = new ArcSummary(
|
||||
"Acte I",
|
||||
null,
|
||||
0,
|
||||
List.of(
|
||||
new ChapterSummary("Ch1", null, 0, List.of()),
|
||||
new ChapterSummary("Ch2", null, 0, List.of()),
|
||||
new ChapterSummary("Ch3", null, 0, List.of())));
|
||||
|
||||
assertEquals(3, arc.getChapters().size());
|
||||
assertTrue(arc.getChapters().stream().anyMatch(c -> "Ch2".equals(c.getName())));
|
||||
assertEquals(3, arc.chapters().size());
|
||||
assertEquals("Ch2", arc.chapters().get(1).name());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.loremind.domain.generationcontext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
@@ -26,57 +27,45 @@ class ChatRequestTest {
|
||||
void buildLoreOnly_leavesCampaignAndEntityNull() {
|
||||
ChatRequest request = ChatRequest.builder()
|
||||
.messages(sampleMessages)
|
||||
.loreContext(LoreStructuralContext.builder()
|
||||
.loreName("Ithoril")
|
||||
.loreDescription("Royaume sombre")
|
||||
.folders(java.util.Map.of())
|
||||
.build())
|
||||
.loreContext(new LoreStructuralContext("Ithoril", "Royaume sombre", Map.of(), List.of()))
|
||||
.build();
|
||||
|
||||
assertEquals(1, request.getMessages().size());
|
||||
assertNotNull(request.getLoreContext());
|
||||
assertEquals("Ithoril", request.getLoreContext().getLoreName());
|
||||
assertNull(request.getPageContext());
|
||||
assertNull(request.getCampaignContext());
|
||||
assertNull(request.getNarrativeEntity());
|
||||
assertEquals(1, request.messages().size());
|
||||
assertNotNull(request.loreContext());
|
||||
assertEquals("Ithoril", request.loreContext().loreName());
|
||||
assertNull(request.pageContext());
|
||||
assertNull(request.campaignContext());
|
||||
assertNull(request.narrativeEntity());
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildLoreWithPageFocus_hasBothContexts() {
|
||||
ChatRequest request = ChatRequest.builder()
|
||||
.messages(sampleMessages)
|
||||
.loreContext(LoreStructuralContext.builder().folders(java.util.Map.of()).build())
|
||||
.pageContext(PageContext.builder()
|
||||
.title("Thorin")
|
||||
.templateName("PNJ")
|
||||
.build())
|
||||
.loreContext(new LoreStructuralContext(null, null, Map.of(), List.of()))
|
||||
.pageContext(new PageContext("Thorin", "PNJ", null, null))
|
||||
.build();
|
||||
|
||||
assertNotNull(request.getLoreContext());
|
||||
assertNotNull(request.getPageContext());
|
||||
assertEquals("Thorin", request.getPageContext().getTitle());
|
||||
assertNotNull(request.loreContext());
|
||||
assertNotNull(request.pageContext());
|
||||
assertEquals("Thorin", request.pageContext().title());
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildCampaignWithNarrativeEntity_hasBothContexts() {
|
||||
ChatRequest request = ChatRequest.builder()
|
||||
.messages(sampleMessages)
|
||||
.campaignContext(CampaignStructuralContext.builder()
|
||||
.campaignName("Les Ombres")
|
||||
.campaignDescription("...")
|
||||
.build())
|
||||
.narrativeEntity(NarrativeEntityContext.builder()
|
||||
.entityType("scene")
|
||||
.title("L'auberge")
|
||||
.fields(java.util.Map.of("location", "Taverne"))
|
||||
.build())
|
||||
.campaignContext(new CampaignStructuralContext(
|
||||
"Les Ombres", "...", List.of(), List.of()))
|
||||
.narrativeEntity(new NarrativeEntityContext(
|
||||
"scene", "L'auberge", Map.of("location", "Taverne")))
|
||||
.build();
|
||||
|
||||
assertNotNull(request.getCampaignContext());
|
||||
assertNotNull(request.getNarrativeEntity());
|
||||
assertEquals("scene", request.getNarrativeEntity().getEntityType());
|
||||
assertNull(request.getLoreContext());
|
||||
assertNull(request.getPageContext());
|
||||
assertNotNull(request.campaignContext());
|
||||
assertNotNull(request.narrativeEntity());
|
||||
assertEquals("scene", request.narrativeEntity().entityType());
|
||||
assertNull(request.loreContext());
|
||||
assertNull(request.pageContext());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -86,10 +75,10 @@ class ChatRequestTest {
|
||||
.messages(sampleMessages)
|
||||
.build();
|
||||
|
||||
assertEquals(1, request.getMessages().size());
|
||||
assertNull(request.getLoreContext());
|
||||
assertNull(request.getPageContext());
|
||||
assertNull(request.getCampaignContext());
|
||||
assertNull(request.getNarrativeEntity());
|
||||
assertEquals(1, request.messages().size());
|
||||
assertNull(request.loreContext());
|
||||
assertNull(request.pageContext());
|
||||
assertNull(request.campaignContext());
|
||||
assertNull(request.narrativeEntity());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,41 +9,38 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour GenerationContext (Value Object pour la generation one-shot).
|
||||
* Verifie la construction via builder et l'egalite structurelle.
|
||||
* Verifie la construction et l'egalite structurelle (record).
|
||||
*/
|
||||
class GenerationContextTest {
|
||||
|
||||
@Test
|
||||
void builder_preservesAllFields() {
|
||||
GenerationContext ctx = GenerationContext.builder()
|
||||
.loreName("Ithoril")
|
||||
.loreDescription("Royaume sombre")
|
||||
.folderName("PNJ")
|
||||
.templateName("Fiche PNJ")
|
||||
.templateFields(List.of("histoire", "motto", "apparence"))
|
||||
.pageTitle("Thorin")
|
||||
.build();
|
||||
void constructor_preservesAllFields() {
|
||||
GenerationContext ctx = new GenerationContext(
|
||||
"Ithoril",
|
||||
"Royaume sombre",
|
||||
"PNJ",
|
||||
"Fiche PNJ",
|
||||
List.of("histoire", "motto", "apparence"),
|
||||
"Thorin");
|
||||
|
||||
assertEquals("Ithoril", ctx.getLoreName());
|
||||
assertEquals("PNJ", ctx.getFolderName());
|
||||
assertEquals("Fiche PNJ", ctx.getTemplateName());
|
||||
assertEquals(3, ctx.getTemplateFields().size());
|
||||
assertEquals("Thorin", ctx.getPageTitle());
|
||||
assertEquals("Ithoril", ctx.loreName());
|
||||
assertEquals("PNJ", ctx.folderName());
|
||||
assertEquals("Fiche PNJ", ctx.templateName());
|
||||
assertEquals(3, ctx.templateFields().size());
|
||||
assertEquals("Thorin", ctx.pageTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoContexts_withSameFields_areEqual() {
|
||||
GenerationContext a = GenerationContext.builder()
|
||||
.loreName("X").pageTitle("A").templateFields(List.of("f1")).build();
|
||||
GenerationContext b = GenerationContext.builder()
|
||||
.loreName("X").pageTitle("A").templateFields(List.of("f1")).build();
|
||||
GenerationContext a = new GenerationContext("X", null, null, null, List.of("f1"), "A");
|
||||
GenerationContext b = new GenerationContext("X", null, null, null, List.of("f1"), "A");
|
||||
assertEquals(a, b);
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoContexts_differingOnPageTitle_areNotEqual() {
|
||||
GenerationContext a = GenerationContext.builder().pageTitle("A").build();
|
||||
GenerationContext b = GenerationContext.builder().pageTitle("B").build();
|
||||
GenerationContext a = new GenerationContext(null, null, null, null, null, "A");
|
||||
GenerationContext b = new GenerationContext(null, null, null, null, null, "B");
|
||||
assertNotEquals(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,66 +12,61 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour LoreStructuralContext et son type imbrique PageSummary.
|
||||
* Valide le @Singular de Lombok sur {@code tags} (alimentation incrementale via
|
||||
* {@code tag(...)} vs initialisation groupee via {@code tags(...)}).
|
||||
* Records purs : aucune dependance technique.
|
||||
*/
|
||||
class LoreStructuralContextTest {
|
||||
|
||||
@Test
|
||||
void builder_preservesFoldersAndTags() {
|
||||
PageSummary pnj = PageSummary.builder()
|
||||
.title("Thorin")
|
||||
.templateName("PNJ")
|
||||
.values(Map.of("histoire", "Nee sous une etoile rouge"))
|
||||
.tags(List.of("pnj", "allie"))
|
||||
.relatedPageTitles(List.of("Taverne du Dragon d'Or"))
|
||||
.build();
|
||||
void constructor_preservesFoldersAndTags() {
|
||||
PageSummary pnj = new PageSummary(
|
||||
"Thorin",
|
||||
"PNJ",
|
||||
Map.of("histoire", "Nee sous une etoile rouge"),
|
||||
List.of("pnj", "allie"),
|
||||
List.of("Taverne du Dragon d'Or"));
|
||||
|
||||
LoreStructuralContext ctx = LoreStructuralContext.builder()
|
||||
.loreName("Ithoril")
|
||||
.loreDescription("Royaume sombre")
|
||||
.folders(Map.of("PNJ", List.of(pnj)))
|
||||
.tag("royaume")
|
||||
.tag("dark-fantasy")
|
||||
.build();
|
||||
LoreStructuralContext ctx = new LoreStructuralContext(
|
||||
"Ithoril",
|
||||
"Royaume sombre",
|
||||
Map.of("PNJ", List.of(pnj)),
|
||||
List.of("royaume", "dark-fantasy"));
|
||||
|
||||
assertEquals("Ithoril", ctx.getLoreName());
|
||||
assertEquals(1, ctx.getFolders().size());
|
||||
assertEquals(1, ctx.getFolders().get("PNJ").size());
|
||||
assertEquals(2, ctx.getTags().size(), "@Singular doit accumuler les appels tag()");
|
||||
assertTrue(ctx.getTags().contains("royaume"));
|
||||
assertTrue(ctx.getTags().contains("dark-fantasy"));
|
||||
assertEquals("Ithoril", ctx.loreName());
|
||||
assertEquals(1, ctx.folders().size());
|
||||
assertEquals(1, ctx.folders().get("PNJ").size());
|
||||
assertEquals(2, ctx.tags().size());
|
||||
assertTrue(ctx.tags().contains("royaume"));
|
||||
assertTrue(ctx.tags().contains("dark-fantasy"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyFolders_areAllowed() {
|
||||
// Dossier vide : legitime (ex: dossier "Lieux" cree mais pas encore peuple).
|
||||
LoreStructuralContext ctx = LoreStructuralContext.builder()
|
||||
.loreName("Vide")
|
||||
.loreDescription("")
|
||||
.folders(Map.of("Lieux", List.of()))
|
||||
.build();
|
||||
LoreStructuralContext ctx = new LoreStructuralContext(
|
||||
"Vide",
|
||||
"",
|
||||
Map.of("Lieux", List.of()),
|
||||
List.of());
|
||||
|
||||
assertNotNull(ctx.getFolders().get("Lieux"));
|
||||
assertTrue(ctx.getFolders().get("Lieux").isEmpty());
|
||||
assertNotNull(ctx.folders().get("Lieux"));
|
||||
assertTrue(ctx.folders().get("Lieux").isEmpty());
|
||||
}
|
||||
|
||||
// --- PageSummary --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void pageSummary_preservesAllFields() {
|
||||
PageSummary ps = PageSummary.builder()
|
||||
.title("Le Donjon du Chaos")
|
||||
.templateName("Lieu")
|
||||
.values(Map.of("histoire", "Bati il y a 1000 ans..."))
|
||||
.tags(List.of("donjon", "ancien"))
|
||||
.relatedPageTitles(List.of("Thorin", "Garde royale"))
|
||||
.build();
|
||||
PageSummary ps = new PageSummary(
|
||||
"Le Donjon du Chaos",
|
||||
"Lieu",
|
||||
Map.of("histoire", "Bati il y a 1000 ans..."),
|
||||
List.of("donjon", "ancien"),
|
||||
List.of("Thorin", "Garde royale"));
|
||||
|
||||
assertEquals("Le Donjon du Chaos", ps.getTitle());
|
||||
assertEquals("Lieu", ps.getTemplateName());
|
||||
assertEquals(1, ps.getValues().size());
|
||||
assertEquals(2, ps.getTags().size());
|
||||
assertEquals(2, ps.getRelatedPageTitles().size());
|
||||
assertEquals("Le Donjon du Chaos", ps.title());
|
||||
assertEquals("Lieu", ps.templateName());
|
||||
assertEquals(1, ps.values().size());
|
||||
assertEquals(2, ps.tags().size());
|
||||
assertEquals(2, ps.relatedPageTitles().size());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,21 +16,17 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
class NarrativeEntityContextTest {
|
||||
|
||||
@Test
|
||||
void builder_preservesAllFields() {
|
||||
void constructor_preservesAllFields() {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
fields.put("themes", "trahison");
|
||||
fields.put("stakes", "la survie du royaume");
|
||||
|
||||
NarrativeEntityContext ctx = NarrativeEntityContext.builder()
|
||||
.entityType("arc")
|
||||
.title("Acte I")
|
||||
.fields(fields)
|
||||
.build();
|
||||
NarrativeEntityContext ctx = new NarrativeEntityContext("arc", "Acte I", fields);
|
||||
|
||||
assertEquals("arc", ctx.getEntityType());
|
||||
assertEquals("Acte I", ctx.getTitle());
|
||||
assertEquals(2, ctx.getFields().size());
|
||||
assertEquals("trahison", ctx.getFields().get("themes"));
|
||||
assertEquals("arc", ctx.entityType());
|
||||
assertEquals("Acte I", ctx.title());
|
||||
assertEquals(2, ctx.fields().size());
|
||||
assertEquals("trahison", ctx.fields().get("themes"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -41,19 +37,15 @@ class NarrativeEntityContextTest {
|
||||
fields.put("timing", "Soir");
|
||||
fields.put("atmosphere", "fumee");
|
||||
|
||||
NarrativeEntityContext ctx = NarrativeEntityContext.builder()
|
||||
.entityType("scene")
|
||||
.title("L'auberge")
|
||||
.fields(fields)
|
||||
.build();
|
||||
NarrativeEntityContext ctx = new NarrativeEntityContext("scene", "L'auberge", fields);
|
||||
|
||||
assertEquals("[location, timing, atmosphere]", ctx.getFields().keySet().toString());
|
||||
assertEquals("[location, timing, atmosphere]", ctx.fields().keySet().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoContexts_differingOnEntityType_areNotEqual() {
|
||||
NarrativeEntityContext a = NarrativeEntityContext.builder().entityType("arc").title("X").build();
|
||||
NarrativeEntityContext b = NarrativeEntityContext.builder().entityType("scene").title("X").build();
|
||||
NarrativeEntityContext a = new NarrativeEntityContext("arc", "X", Map.of());
|
||||
NarrativeEntityContext b = new NarrativeEntityContext("scene", "X", Map.of());
|
||||
assertNotEquals(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,31 +14,29 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
class PageContextTest {
|
||||
|
||||
@Test
|
||||
void builder_preservesAllFields() {
|
||||
PageContext ctx = PageContext.builder()
|
||||
.title("Thorin")
|
||||
.templateName("PNJ")
|
||||
.templateFields(List.of("histoire", "apparence", "motto"))
|
||||
.values(Map.of("histoire", "Nee sous une etoile rouge"))
|
||||
.build();
|
||||
void constructor_preservesAllFields() {
|
||||
PageContext ctx = new PageContext(
|
||||
"Thorin",
|
||||
"PNJ",
|
||||
List.of("histoire", "apparence", "motto"),
|
||||
Map.of("histoire", "Nee sous une etoile rouge"));
|
||||
|
||||
assertEquals("Thorin", ctx.getTitle());
|
||||
assertEquals("PNJ", ctx.getTemplateName());
|
||||
assertEquals(3, ctx.getTemplateFields().size());
|
||||
assertEquals(1, ctx.getValues().size());
|
||||
assertEquals("Thorin", ctx.title());
|
||||
assertEquals("PNJ", ctx.templateName());
|
||||
assertEquals(3, ctx.templateFields().size());
|
||||
assertEquals(1, ctx.values().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyValues_areAllowed() {
|
||||
// Page vierge : template defini mais aucun champ rempli (cas generation ex-nihilo).
|
||||
PageContext ctx = PageContext.builder()
|
||||
.title("Nouveau PNJ")
|
||||
.templateName("PNJ")
|
||||
.templateFields(List.of("histoire", "apparence"))
|
||||
.values(Map.of())
|
||||
.build();
|
||||
PageContext ctx = new PageContext(
|
||||
"Nouveau PNJ",
|
||||
"PNJ",
|
||||
List.of("histoire", "apparence"),
|
||||
Map.of());
|
||||
|
||||
assertTrue(ctx.getValues().isEmpty());
|
||||
assertEquals(2, ctx.getTemplateFields().size());
|
||||
assertTrue(ctx.values().isEmpty());
|
||||
assertEquals(2, ctx.templateFields().size());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,12 +83,8 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_loreContext_includesBasicFields() {
|
||||
LoreStructuralContext lore = LoreStructuralContext.builder()
|
||||
.loreName("Ithoril")
|
||||
.loreDescription("Royaume sombre")
|
||||
.folders(Map.of())
|
||||
.tag("dark-fantasy")
|
||||
.build();
|
||||
LoreStructuralContext lore = new LoreStructuralContext(
|
||||
"Ithoril", "Royaume sombre", Map.of(), List.of("dark-fantasy"));
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
|
||||
|
||||
Map<String, Object> payload = builder.build(req);
|
||||
@@ -103,17 +99,10 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_pageSummary_omitsEmptyValuesTagsAndRelated() {
|
||||
PageSummary minimal = PageSummary.builder()
|
||||
.title("Thorin")
|
||||
.templateName("PNJ")
|
||||
.values(Map.of())
|
||||
.tags(List.of())
|
||||
.relatedPageTitles(List.of())
|
||||
.build();
|
||||
LoreStructuralContext lore = LoreStructuralContext.builder()
|
||||
.loreName("X").loreDescription("")
|
||||
.folders(Map.of("PNJ", List.of(minimal)))
|
||||
.build();
|
||||
PageSummary minimal = new PageSummary("Thorin", "PNJ",
|
||||
Map.of(), List.of(), List.of());
|
||||
LoreStructuralContext lore = new LoreStructuralContext(
|
||||
"X", "", Map.of("PNJ", List.of(minimal)), List.of());
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
|
||||
|
||||
Map<String, Object> payload = builder.build(req);
|
||||
@@ -132,17 +121,12 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_pageSummary_includesNonEmptyValuesTagsAndRelated() {
|
||||
PageSummary full = PageSummary.builder()
|
||||
.title("Thorin")
|
||||
.templateName("PNJ")
|
||||
.values(Map.of("histoire", "Nee sous une etoile rouge"))
|
||||
.tags(List.of("pnj", "allie"))
|
||||
.relatedPageTitles(List.of("Taverne du Dragon d'Or"))
|
||||
.build();
|
||||
LoreStructuralContext lore = LoreStructuralContext.builder()
|
||||
.loreName("X").loreDescription("")
|
||||
.folders(Map.of("PNJ", List.of(full)))
|
||||
.build();
|
||||
PageSummary full = new PageSummary("Thorin", "PNJ",
|
||||
Map.of("histoire", "Nee sous une etoile rouge"),
|
||||
List.of("pnj", "allie"),
|
||||
List.of("Taverne du Dragon d'Or"));
|
||||
LoreStructuralContext lore = new LoreStructuralContext(
|
||||
"X", "", Map.of("PNJ", List.of(full)), List.of());
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
|
||||
|
||||
Map<String, Object> payload = builder.build(req);
|
||||
@@ -161,12 +145,8 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_pageContext_includesAllFields() {
|
||||
PageContext pc = PageContext.builder()
|
||||
.title("Thorin")
|
||||
.templateName("PNJ")
|
||||
.templateFields(List.of("histoire", "motto"))
|
||||
.values(Map.of("histoire", "..."))
|
||||
.build();
|
||||
PageContext pc = new PageContext("Thorin", "PNJ",
|
||||
List.of("histoire", "motto"), Map.of("histoire", "..."));
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).pageContext(pc).build();
|
||||
|
||||
Map<String, Object> payload = builder.build(req);
|
||||
@@ -182,17 +162,12 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_campaignContext_serializesFullNarrativeTree() {
|
||||
BranchHint branch = BranchHint.builder()
|
||||
.label("fuite").targetSceneName("La poursuite").condition("HP < 50%").build();
|
||||
SceneSummary scene = SceneSummary.builder()
|
||||
.name("L'auberge").description("Rencontre tendue")
|
||||
.illustrationCount(3).branch(branch).build();
|
||||
ChapterSummary chapter = ChapterSummary.builder()
|
||||
.name("L'arrivee").description("...").scene(scene).build();
|
||||
ArcSummary arc = ArcSummary.builder()
|
||||
.name("Acte I").description("Mise en place").illustrationCount(1).chapter(chapter).build();
|
||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
||||
.campaignName("Les Ombres").campaignDescription("dark fantasy").arc(arc).build();
|
||||
BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%");
|
||||
SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch));
|
||||
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
|
||||
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter));
|
||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||
"Les Ombres", "dark fantasy", List.of(arc), List.of());
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||
|
||||
Map<String, Object> payload = builder.build(req);
|
||||
@@ -223,9 +198,9 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_arcSummary_omitsIllustrationCount_whenZero() {
|
||||
ArcSummary arc = ArcSummary.builder().name("A").description("").illustrationCount(0).build();
|
||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
||||
.campaignName("X").campaignDescription("").arc(arc).build();
|
||||
ArcSummary arc = new ArcSummary("A", "", 0, List.of());
|
||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||
"X", "", List.of(arc), List.of());
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||
|
||||
Map<String, Object> payload = builder.build(req);
|
||||
@@ -238,11 +213,11 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_sceneSummary_omitsBranches_whenEmpty() {
|
||||
SceneSummary scene = SceneSummary.builder().name("S").description("").build();
|
||||
ChapterSummary chapter = ChapterSummary.builder().name("Ch").description("").scene(scene).build();
|
||||
ArcSummary arc = ArcSummary.builder().name("A").description("").chapter(chapter).build();
|
||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
||||
.campaignName("X").campaignDescription("").arc(arc).build();
|
||||
SceneSummary scene = new SceneSummary("S", "", 0, List.of());
|
||||
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
||||
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||
"X", "", List.of(arc), List.of());
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||
|
||||
Map<String, Object> payload = builder.build(req);
|
||||
@@ -256,12 +231,12 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_branchHint_omitsCondition_whenBlank() {
|
||||
BranchHint branch = BranchHint.builder().label("X").targetSceneName("Y").condition(" ").build();
|
||||
SceneSummary scene = SceneSummary.builder().name("S").description("").branch(branch).build();
|
||||
ChapterSummary chapter = ChapterSummary.builder().name("Ch").description("").scene(scene).build();
|
||||
ArcSummary arc = ArcSummary.builder().name("A").description("").chapter(chapter).build();
|
||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
||||
.campaignName("X").campaignDescription("").arc(arc).build();
|
||||
BranchHint branch = new BranchHint("X", "Y", " ");
|
||||
SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch));
|
||||
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
||||
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||
"X", "", List.of(arc), List.of());
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||
|
||||
Map<String, Object> payload = builder.build(req);
|
||||
@@ -278,10 +253,8 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_narrativeEntity_includesAllFields() {
|
||||
NarrativeEntityContext entity = NarrativeEntityContext.builder()
|
||||
.entityType("scene").title("L'auberge")
|
||||
.fields(Map.of("location", "Taverne", "timing", "Soir"))
|
||||
.build();
|
||||
NarrativeEntityContext entity = new NarrativeEntityContext("scene", "L'auberge",
|
||||
Map.of("location", "Taverne", "timing", "Soir"));
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).narrativeEntity(entity).build();
|
||||
|
||||
Map<String, Object> payload = builder.build(req);
|
||||
@@ -295,10 +268,9 @@ class BrainChatPayloadBuilderTest {
|
||||
|
||||
@Test
|
||||
void build_campaignScenario_includesBothContextsAndEntity() {
|
||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
||||
.campaignName("X").campaignDescription("").build();
|
||||
NarrativeEntityContext entity = NarrativeEntityContext.builder()
|
||||
.entityType("arc").title("T").fields(Map.of()).build();
|
||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||
"X", "", List.of(), List.of());
|
||||
NarrativeEntityContext entity = new NarrativeEntityContext("arc", "T", Map.of());
|
||||
ChatRequest req = ChatRequest.builder()
|
||||
.messages(sampleMessages)
|
||||
.campaignContext(camp)
|
||||
|
||||
@@ -48,27 +48,21 @@ class SceneBranchListJsonConverterTest {
|
||||
|
||||
@Test
|
||||
void roundTrip_preservesAllBranchFields() {
|
||||
// Test critique : depend de @Jacksonized sur SceneBranch.
|
||||
// Test critique : Jackson doit reconstruire SceneBranch (record) via
|
||||
// son constructeur canonique sans aucune annotation.
|
||||
List<SceneBranch> source = List.of(
|
||||
SceneBranch.builder()
|
||||
.label("si les joueurs attaquent")
|
||||
.targetSceneId("sc-combat")
|
||||
.condition("initiative > 15")
|
||||
.build(),
|
||||
SceneBranch.builder()
|
||||
.label("si les joueurs fuient")
|
||||
.targetSceneId("sc-poursuite")
|
||||
.build()
|
||||
new SceneBranch("si les joueurs attaquent", "sc-combat", "initiative > 15"),
|
||||
SceneBranch.of("si les joueurs fuient", "sc-poursuite")
|
||||
);
|
||||
|
||||
String json = converter.convertToDatabaseColumn(source);
|
||||
List<SceneBranch> back = converter.convertToEntityAttribute(json);
|
||||
|
||||
assertEquals(2, back.size());
|
||||
assertEquals("si les joueurs attaquent", back.get(0).getLabel());
|
||||
assertEquals("sc-combat", back.get(0).getTargetSceneId());
|
||||
assertEquals("initiative > 15", back.get(0).getCondition());
|
||||
assertEquals("sc-poursuite", back.get(1).getTargetSceneId());
|
||||
assertNull(back.get(1).getCondition(), "condition absente doit rester null apres round-trip");
|
||||
assertEquals("si les joueurs attaquent", back.get(0).label());
|
||||
assertEquals("sc-combat", back.get(0).targetSceneId());
|
||||
assertEquals("initiative > 15", back.get(0).condition());
|
||||
assertEquals("sc-poursuite", back.get(1).targetSceneId());
|
||||
assertNull(back.get(1).condition(), "condition absente doit rester null apres round-trip");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,13 +70,13 @@ class PostgresSceneRepositoryTest {
|
||||
|
||||
@Test
|
||||
void save_scenePreservesBranches_viaJsonbRoundTrip() {
|
||||
// Le critique : le @Jacksonized de SceneBranch doit permettre la
|
||||
// reconstruction via builder apres serialisation Jackson.
|
||||
// Le critique : SceneBranch (record) doit etre reconstructible par
|
||||
// Jackson via le constructeur canonique apres serialisation JSON.
|
||||
Scene scene = Scene.builder()
|
||||
.chapterId(chapterId).name("Decision").order(0)
|
||||
.branches(List.of(
|
||||
SceneBranch.builder().label("fuite").targetSceneId("sc-2").condition("HP bas").build(),
|
||||
SceneBranch.builder().label("combat").targetSceneId("sc-3").build()
|
||||
new SceneBranch("fuite", "sc-2", "HP bas"),
|
||||
SceneBranch.of("combat", "sc-3")
|
||||
))
|
||||
.build();
|
||||
|
||||
@@ -84,10 +84,10 @@ class PostgresSceneRepositoryTest {
|
||||
Scene r = repository.findById(saved.getId()).orElseThrow();
|
||||
|
||||
assertEquals(2, r.getBranches().size());
|
||||
assertEquals("fuite", r.getBranches().get(0).getLabel());
|
||||
assertEquals("sc-2", r.getBranches().get(0).getTargetSceneId());
|
||||
assertEquals("HP bas", r.getBranches().get(0).getCondition());
|
||||
assertEquals("combat", r.getBranches().get(1).getLabel());
|
||||
assertEquals("fuite", r.getBranches().get(0).label());
|
||||
assertEquals("sc-2", r.getBranches().get(0).targetSceneId());
|
||||
assertEquals("HP bas", r.getBranches().get(0).condition());
|
||||
assertEquals("combat", r.getBranches().get(1).label());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user