9 Commits

Author SHA1 Message Date
41fda9aeee Ajout d'un script pour installation automatique du produit
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 45s
Build & Push Images / build (core) (push) Successful in 1m16s
Build & Push Images / build (web) (push) Successful in 1m26s
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
2026-04-25 13:24:32 +02:00
550078268c Evolutions :
Some checks failed
Build & Push Images / build (brain) (push) Successful in 55s
Build & Push Images / build (core) (push) Successful in 1m35s
E2E Tests / e2e (push) Failing after 4m10s
Build & Push Images / build (web) (push) Successful in 2m0s
- Ajout d'icônes dans la scène, chapitre et arc
- Possibilité de bouger les cases dans la partie graphe et les textes associés si ces derniers ne sont pas visibles
- Changement sur le thème du graphe : mode sombre et plus blanc
- Barre d'action en haut, même pour la partie scène
- Mode sticky corrigé : plus de trou entre le haut du navigateur web et de la barre d'action

Passage version 0.6.5
2026-04-25 11:41:14 +02:00
0582690dca Correction d'un test unitaire
Some checks failed
E2E Tests / e2e (push) Failing after 3m43s
Ajout d'un champs image dans les templates par défaut en + du champs nom, description pour avoir un exemple
Correction du visuel du champs d'ajout lors de la modification d'un template (apparition ligne pleine au lieu de texte en pointillé)
Ajout d'un intercepteur pour la partie démo de l'application afin de bien rafraichir le cache angular lorsque le temps de démo est expiré
2026-04-25 09:23:56 +02:00
88278bd1dd Fix : problème d'ascenseur en bas de la page au niveau des templates
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
E2E Tests / e2e (push) Failing after 4m13s
Build & Push Images / build (web) (push) Successful in 1m53s
Sélection du template par défaut lors de la création d'une page en fonction du dossier
Passage v0.6.2
2026-04-25 01:39:05 +02:00
d24d6459a0 Ajout de test, correctif d'un problème d'horloge pour le workflow gitea actions pour le e2e
Some checks failed
E2E Tests / e2e (push) Failing after 3m33s
2026-04-25 00:51:32 +02:00
4b866e5212 Fix workflow gitea action pour e2e (tests automatisés via playwright) + correction d'une incohérence dans l'API coté java. Ajout d'autres tests utilisateur
Some checks failed
E2E Tests / e2e (push) Failing after 2m31s
2026-04-25 00:45:04 +02:00
6c6bd20f0d Mise en place de tests utilisateurs avec playwright pour la partie angular + corrections au niveau des labels avec for et id pour cliquer dessus
Some checks failed
E2E Tests / e2e (push) Failing after 5m30s
2026-04-25 00:25:53 +02:00
2764228abf Fix rate limit derriere Cloudflare + CORS sur POST demo 2026-04-24 08:55:40 +02:00
f95d69c915 Fix CORS 403 sur POST : passer APP_CORS_ALLOWED_ORIGINS au core démo 2026-04-24 08:46:26 +02:00
154 changed files with 4687 additions and 1043 deletions

View File

@@ -38,3 +38,15 @@ LLM_MODEL=gemma4:26b
# 1min.ai (si LLM_PROVIDER=onemin) # 1min.ai (si LLM_PROVIDER=onemin)
ONEMIN_API_KEY= ONEMIN_API_KEY=
ONEMIN_MODEL=gpt-4o-mini ONEMIN_MODEL=gpt-4o-mini
# --- Mises a jour automatiques (Watchtower) ------------------------------
# Watchtower verifie les nouvelles versions de core/brain/web et permet
# le declenchement manuel via l'UI (bouton "Mettre a jour"). Postgres et
# MinIO sont exclus volontairement.
#
# Activer : COMPOSE_PROFILES=autoupdate + WATCHTOWER_TOKEN non vide.
# COMPOSE_PROFILES=autoupdate
# WATCHTOWER_TOKEN=change-me-use-openssl-rand-hex-32
# WATCHTOWER_MONITOR_ONLY=false # true = detecter sans appliquer
# WATCHTOWER_SCHEDULE=0 0 4 * * *
# TZ=Europe/Paris

95
.gitea/workflows/e2e.yml Normal file
View File

@@ -0,0 +1,95 @@
name: E2E Tests
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Create .env for stack
run: |
cat > .env <<'EOF'
POSTGRES_PASSWORD=ci-postgres-pass
BRAIN_INTERNAL_SECRET=ci-brain-secret
ADMIN_USERNAME=admin
ADMIN_PASSWORD=ci-admin-pass
WEB_PORT=8081
LLM_PROVIDER=ollama
EOF
- name: Build & start stack
run: |
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build
- name: Attach runner to compose network
run: |
NET=$(docker network ls --format '{{.Name}}' | grep -E '(^|_)loremind(_|$)' | grep -i default | head -1)
if [ -z "$NET" ]; then
echo "Compose network not found" >&2
docker network ls
exit 1
fi
echo "Connecting $(hostname) to network $NET"
docker network connect "$NET" "$(hostname)"
- name: Wait for web to be ready
run: |
timeout 180 bash -c 'until curl -sf http://web/ > /dev/null; do echo "waiting..."; sleep 3; done'
- name: Install web deps
working-directory: web
run: npm ci
- name: Work around runner clock skew for apt
run: |
sudo tee /etc/apt/apt.conf.d/99no-check-valid-until >/dev/null <<'EOF'
Acquire::Check-Valid-Until "false";
Acquire::Check-Date "false";
EOF
- name: Install Playwright browsers
working-directory: web
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
working-directory: web
env:
E2E_BASE_URL: http://web
CI: 'true'
run: npm run e2e
- name: Dump container logs on failure
if: failure()
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: web/playwright-report/
retention-days: 14
- name: Stop stack
if: always()
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml down -v

6
.gitignore vendored
View File

@@ -53,6 +53,12 @@ yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
coverage/ coverage/
# Playwright (E2E)
web/test-results/
web/playwright-report/
web/blob-report/
web/playwright/.cache/
# ============================================================================ # ============================================================================
# IDE / Editeurs # IDE / Editeurs
# ============================================================================ # ============================================================================

View File

@@ -40,7 +40,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.6.1", version="0.6.6",
) )

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.6.1</version> <version>0.6.6</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>

View File

@@ -36,11 +36,16 @@ public class ArcService {
public record DeletionImpact(int chapters, int scenes) {} public record DeletionImpact(int chapters, int scenes) {}
public Arc createArc(String name, String description, String campaignId, int order) { public Arc createArc(String name, String description, String campaignId, int order) {
return createArc(name, description, campaignId, order, null);
}
public Arc createArc(String name, String description, String campaignId, int order, String icon) {
Arc arc = Arc.builder() Arc arc = Arc.builder()
.name(name) .name(name)
.description(description) .description(description)
.campaignId(campaignId) .campaignId(campaignId)
.order(order) .order(order)
.icon(icon)
.build(); .build();
return arcRepository.save(arc); return arcRepository.save(arc);
} }

View File

@@ -30,11 +30,16 @@ public class ChapterService {
public record DeletionImpact(int scenes) {} public record DeletionImpact(int scenes) {}
public Chapter createChapter(String name, String description, String arcId, int order) { public Chapter createChapter(String name, String description, String arcId, int order) {
return createChapter(name, description, arcId, order, null);
}
public Chapter createChapter(String name, String description, String arcId, int order, String icon) {
Chapter chapter = Chapter.builder() Chapter chapter = Chapter.builder()
.name(name) .name(name)
.description(description) .description(description)
.arcId(arcId) .arcId(arcId)
.order(order) .order(order)
.icon(icon)
.build(); .build();
return chapterRepository.save(chapter); return chapterRepository.save(chapter);
} }

View File

@@ -26,11 +26,16 @@ public class SceneService {
} }
public Scene createScene(String name, String description, String chapterId, int order) { public Scene createScene(String name, String description, String chapterId, int order) {
return createScene(name, description, chapterId, order, null);
}
public Scene createScene(String name, String description, String chapterId, int order, String icon) {
Scene scene = Scene.builder() Scene scene = Scene.builder()
.name(name) .name(name)
.description(description) .description(description)
.chapterId(chapterId) .chapterId(chapterId)
.order(order) .order(order)
.icon(icon)
.build(); .build();
return sceneRepository.save(scene); return sceneRepository.save(scene);
} }
@@ -93,7 +98,7 @@ public class SceneService {
.collect(Collectors.toSet()); .collect(Collectors.toSet());
for (SceneBranch b : branches) { for (SceneBranch b : branches) {
String target = b.getTargetSceneId(); String target = b.targetSceneId();
if (target == null || target.isBlank()) { if (target == null || target.isBlank()) {
throw new IllegalArgumentException("Une branche doit avoir une scène de destination"); throw new IllegalArgumentException("Une branche doit avoir une scène de destination");
} }

View File

@@ -45,11 +45,7 @@ public class GameSystemContextBuilder {
private GameSystemContext build(GameSystem gs, GenerationIntent intent) { private GameSystemContext build(GameSystem gs, GenerationIntent intent) {
Map<String, String> allSections = parseH2Sections(gs.getRulesMarkdown()); Map<String, String> allSections = parseH2Sections(gs.getRulesMarkdown());
Map<String, String> filtered = filterByIntent(allSections, intent); Map<String, String> filtered = filterByIntent(allSections, intent);
return GameSystemContext.builder() return new GameSystemContext(gs.getName(), gs.getDescription(), filtered);
.systemName(gs.getName())
.systemDescription(gs.getDescription())
.sections(filtered)
.build();
} }
/** /**

View File

@@ -79,12 +79,11 @@ public class CampaignStructuralContextBuilder {
.map(this::toCharacterSummary) .map(this::toCharacterSummary)
.collect(Collectors.toList()); .collect(Collectors.toList());
return CampaignStructuralContext.builder() return new CampaignStructuralContext(
.campaignName(campaign.getName()) campaign.getName(),
.campaignDescription(campaign.getDescription()) campaign.getDescription(),
.arcs(arcs) arcs,
.characters(characters) characters);
.build();
} }
/** /**
@@ -93,10 +92,7 @@ public class CampaignStructuralContextBuilder {
* sans injecter toute sa fiche. * sans injecter toute sa fiche.
*/ */
private CharacterSummary toCharacterSummary(Character c) { private CharacterSummary toCharacterSummary(Character c) {
return CharacterSummary.builder() return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent()));
.name(c.getName())
.snippet(extractSnippet(c.getMarkdownContent()))
.build();
} }
private static String extractSnippet(String markdown) { private static String extractSnippet(String markdown) {
@@ -115,12 +111,11 @@ public class CampaignStructuralContextBuilder {
.sorted(Comparator.comparingInt(Chapter::getOrder)) .sorted(Comparator.comparingInt(Chapter::getOrder))
.map(this::toChapterSummary) .map(this::toChapterSummary)
.collect(Collectors.toList()); .collect(Collectors.toList());
return ArcSummary.builder() return new ArcSummary(
.name(arc.getName()) arc.getName(),
.description(arc.getDescription()) arc.getDescription(),
.illustrationCount(countImages(arc.getIllustrationImageIds())) countImages(arc.getIllustrationImageIds()),
.chapters(chapters) chapters);
.build();
} }
private ChapterSummary toChapterSummary(Chapter chapter) { private ChapterSummary toChapterSummary(Chapter chapter) {
@@ -137,32 +132,28 @@ public class CampaignStructuralContextBuilder {
.map(s -> toSceneSummary(s, nameById)) .map(s -> toSceneSummary(s, nameById))
.collect(Collectors.toList()); .collect(Collectors.toList());
return ChapterSummary.builder() return new ChapterSummary(
.name(chapter.getName()) chapter.getName(),
.description(chapter.getDescription()) chapter.getDescription(),
.illustrationCount(countImages(chapter.getIllustrationImageIds())) countImages(chapter.getIllustrationImageIds()),
.scenes(summaries) summaries);
.build();
} }
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) { private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
List<BranchHint> hints = scene.getBranches() == null List<BranchHint> hints = scene.getBranches() == null
? List.of() ? List.of()
: scene.getBranches().stream() : scene.getBranches().stream()
.map(b -> BranchHint.builder() .map(b -> new BranchHint(
.label(b.getLabel()) b.label(),
.targetSceneName(nameById.getOrDefault( nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"),
b.getTargetSceneId(), "(scène inconnue)")) b.condition()))
.condition(b.getCondition())
.build())
.collect(Collectors.toList()); .collect(Collectors.toList());
return SceneSummary.builder() return new SceneSummary(
.name(scene.getName()) scene.getName(),
.description(scene.getDescription()) scene.getDescription(),
.illustrationCount(countImages(scene.getIllustrationImageIds())) countImages(scene.getIllustrationImageIds()),
.branches(hints) hints);
.build();
} }
/** Helper defensif : compte les illustrations attachees (null-safe). */ /** Helper defensif : compte les illustrations attachees (null-safe). */

View File

@@ -67,16 +67,15 @@ public class GeneratePageValuesUseCase {
requireNonEmptyFields(template); requireNonEmptyFields(template);
GenerationContext context = GenerationContext.builder() // Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
.loreName(lore.getName()) // necessitent un workflow different (pas de generation LLM texte).
.loreDescription(lore.getDescription()) GenerationContext context = new GenerationContext(
.folderName(folder.getName()) lore.getName(),
.templateName(template.getName()) lore.getDescription(),
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE folder.getName(),
// necessitent un workflow different (pas de generation LLM texte). template.getName(),
.templateFields(template.textFieldNames()) template.textFieldNames(),
.pageTitle(page.getTitle()) page.getTitle());
.build();
GenerationResult result = aiProvider.generatePage(context); GenerationResult result = aiProvider.generatePage(context);
return result.values(); return result.values();

View File

@@ -82,12 +82,11 @@ public class LoreStructuralContextBuilder {
Map<String, String> pageTitleById = pages.stream() Map<String, String> pageTitleById = pages.stream()
.collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a)); .collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a));
return LoreStructuralContext.builder() return new LoreStructuralContext(
.loreName(lore.getName()) lore.getName(),
.loreDescription(lore.getDescription()) lore.getDescription(),
.folders(buildFoldersMap(nodes, pages, templateNameById, pageTitleById)) buildFoldersMap(nodes, pages, templateNameById, pageTitleById),
.tags(extractUniqueTags(pages)) extractUniqueTags(pages));
.build();
} }
private Map<String, List<PageSummary>> buildFoldersMap( private Map<String, List<PageSummary>> buildFoldersMap(
@@ -118,13 +117,12 @@ public class LoreStructuralContextBuilder {
Page page, Page page,
Map<String, String> templateNameById, Map<String, String> templateNameById,
Map<String, String> pageTitleById) { Map<String, String> pageTitleById) {
return PageSummary.builder() return new PageSummary(
.title(page.getTitle()) page.getTitle(),
.templateName(templateNameById.getOrDefault(page.getTemplateId(), "?")) templateNameById.getOrDefault(page.getTemplateId(), "?"),
.values(truncatedValues(page.getValues())) truncatedValues(page.getValues()),
.tags(page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList()) page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList(),
.relatedPageTitles(resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById)) resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById));
.build();
} }
/** /**

View File

@@ -91,11 +91,7 @@ public class NarrativeEntityContextBuilder {
putField(fields, "rewards", a.getRewards()); putField(fields, "rewards", a.getRewards());
putField(fields, "resolution", a.getResolution()); putField(fields, "resolution", a.getResolution());
putField(fields, "gmNotes", a.getGmNotes()); putField(fields, "gmNotes", a.getGmNotes());
return NarrativeEntityContext.builder() return new NarrativeEntityContext("arc", a.getName(), fields);
.entityType("arc")
.title(a.getName())
.fields(fields)
.build();
} }
private NarrativeEntityContext fromChapter(Chapter c) { private NarrativeEntityContext fromChapter(Chapter c) {
@@ -104,11 +100,7 @@ public class NarrativeEntityContextBuilder {
putField(fields, "playerObjectives", c.getPlayerObjectives()); putField(fields, "playerObjectives", c.getPlayerObjectives());
putField(fields, "narrativeStakes", c.getNarrativeStakes()); putField(fields, "narrativeStakes", c.getNarrativeStakes());
putField(fields, "gmNotes", c.getGmNotes()); putField(fields, "gmNotes", c.getGmNotes());
return NarrativeEntityContext.builder() return new NarrativeEntityContext("chapter", c.getName(), fields);
.entityType("chapter")
.title(c.getName())
.fields(fields)
.build();
} }
private NarrativeEntityContext fromScene(Scene s) { private NarrativeEntityContext fromScene(Scene s) {
@@ -122,21 +114,13 @@ public class NarrativeEntityContextBuilder {
putField(fields, "combatDifficulty", s.getCombatDifficulty()); putField(fields, "combatDifficulty", s.getCombatDifficulty());
putField(fields, "enemies", s.getEnemies()); putField(fields, "enemies", s.getEnemies());
putField(fields, "gmSecretNotes", s.getGmSecretNotes()); putField(fields, "gmSecretNotes", s.getGmSecretNotes());
return NarrativeEntityContext.builder() return new NarrativeEntityContext("scene", s.getName(), fields);
.entityType("scene")
.title(s.getName())
.fields(fields)
.build();
} }
private NarrativeEntityContext fromCharacter(Character c) { private NarrativeEntityContext fromCharacter(Character c) {
Map<String, String> fields = new LinkedHashMap<>(); Map<String, String> fields = new LinkedHashMap<>();
putField(fields, "fiche complète (markdown)", c.getMarkdownContent()); putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
return NarrativeEntityContext.builder() return new NarrativeEntityContext("character", c.getName(), fields);
.entityType("character")
.title(c.getName())
.fields(fields)
.build();
} }
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */ /** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */

View File

@@ -107,11 +107,6 @@ public class StreamChatForLoreUseCase {
? page.getValues() ? page.getValues()
: Collections.emptyMap(); : Collections.emptyMap();
return PageContext.builder() return new PageContext(page.getTitle(), templateName, templateFields, values);
.title(page.getTitle())
.templateName(templateName)
.templateFields(templateFields)
.values(values)
.build();
} }
} }

View File

@@ -21,6 +21,9 @@ public class Arc {
private String campaignId; // Référence vers la Campaign parente private String campaignId; // Référence vers la Campaign parente
private int order; // Ordre de l'arc dans la campagne private int order; // Ordre de l'arc dans la campagne
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/) // Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
private String themes; // Thèmes principaux explorés dans cet arc private String themes; // Thèmes principaux explorés dans cet arc
private String stakes; // Enjeux globaux pour les personnages private String stakes; // Enjeux globaux pour les personnages

View File

@@ -21,6 +21,9 @@ public class Chapter {
private String arcId; // Référence vers l'Arc parent private String arcId; // Référence vers l'Arc parent
private int order; // Ordre du chapitre dans l'arc private int order; // Ordre du chapitre dans l'arc
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/) // Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT) private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
private String playerObjectives; // Objectifs des joueurs dans ce chapitre private String playerObjectives; // Objectifs des joueurs dans ce chapitre

View File

@@ -21,6 +21,9 @@ public class Scene {
private String chapterId; // Référence vers le Chapter parent private String chapterId; // Référence vers le Chapter parent
private int order; // Ordre de la scène dans le chapitre private int order; // Ordre de la scène dans le chapitre
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// === Contexte et ambiance === // === Contexte et ambiance ===
private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or) private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or)
private String timing; // Moment (ex: Soir, à la tombée de la nuit) private String timing; // Moment (ex: Soir, à la tombée de la nuit)

View File

@@ -1,31 +1,25 @@
package com.loremind.domain.campaigncontext; 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. * 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. * Décrit un choix offert aux joueurs et la scène de destination associée.
* <p> * <p>
* Immuable (@Value) : pour "modifier" une branche on la remplace. * Record Java : immuable par construction, sans aucune dépendance technique
* @Jacksonized : permet à Jackson (sérialisation JSON via le converter JPA) * (pas de Lombok, pas de Jackson). Jackson 2.12+ sait sérialiser/désérialiser
* de reconstruire l'objet en passant par le builder malgré l'absence de setters. * les records nativement via le constructeur canonique — c'est ce dont
* dépend le SceneBranchListJsonConverter pour le stockage JSONB.
* <p> * <p>
* Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter * Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter
* (validation portée par SceneService). * (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 public record SceneBranch(String label, String targetSceneId, String condition) {
@Builder
@Jacksonized
public class SceneBranch {
/** Libellé du choix (ex: "Si les joueurs attaquent le garde"). */ /** Raccourci pour construire une branche sans condition (cas le plus courant). */
String label; public static SceneBranch of(String label, String targetSceneId) {
return new SceneBranch(label, targetSceneId, null);
/** Id de la Scene de destination, intra-chapitre uniquement. */ }
String targetSceneId;
/** Notes MJ privées sur la condition de déclenchement (optionnel). */
String condition;
} }

View File

@@ -1,9 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
import java.util.List; import java.util.List;
/** /**
@@ -22,16 +18,16 @@ import java.util.List;
* <p> * <p>
* La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant * La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant
* fait par le use case côté application layer). * 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 public record CampaignStructuralContext(
@Builder String campaignName,
public class CampaignStructuralContext { String campaignDescription,
List<ArcSummary> arcs,
String campaignName; List<CharacterSummary> characters) {
String campaignDescription;
@Singular List<ArcSummary> arcs;
/** Personnages joueurs (PJ) de la campagne. Vide si aucun. */
@Singular List<CharacterSummary> characters;
/** /**
* Résumé d'un PJ : nom + snippet court du markdown. * 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 * La fiche complète n'est injectée que si le PJ est l'entité focus
* (via NarrativeEntityContext, entity_type="character"). * (via NarrativeEntityContext, entity_type="character").
*/ */
@Value public record CharacterSummary(String name, String snippet) {
@Builder
public static class CharacterSummary {
String name;
String snippet;
} }
/** Résumé d'un arc : nom + description courte + ses chapitres. */ /**
@Value * Résumé d'un arc : nom + description courte + ses chapitres.
@Builder *
public static class ArcSummary { * @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA).
String name; */
String description; public record ArcSummary(
/** Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA). */ String name,
int illustrationCount; String description,
@Singular List<ChapterSummary> chapters; int illustrationCount,
List<ChapterSummary> chapters) {
} }
/** Résumé d'un chapitre : nom + description courte + ses scènes. */ /** Résumé d'un chapitre : nom + description courte + ses scènes. */
@Value public record ChapterSummary(
@Builder String name,
public static class ChapterSummary { String description,
String name; int illustrationCount,
String description; List<SceneSummary> scenes) {
int illustrationCount;
@Singular List<SceneSummary> scenes;
} }
/** Résumé d'une scène : nom + description courte + branches narratives. */ /** Résumé d'une scène : nom + description courte + branches narratives. */
@Value public record SceneSummary(
@Builder String name,
public static class SceneSummary { String description,
String name; int illustrationCount,
String description; List<BranchHint> branches) {
int illustrationCount;
@Singular List<BranchHint> branches;
} }
/** Indice d'une branche narrative vers une autre scène du même chapitre. */ /**
@Value * Indice d'une branche narrative vers une autre scène du même chapitre.
@Builder *
public static class BranchHint { * @param label Libellé du choix joueur (ex: "Si les joueurs attaquent le garde").
/** 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).
String label; * @param condition Condition MJ privée (optionnel).
/** Nom de la scène cible (résolu depuis targetSceneId côté builder). */ */
String targetSceneName; public record BranchHint(String label, String targetSceneName, String condition) {
/** Condition MJ privée (optionnel). */
String condition;
} }
} }

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List; 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 * 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, * ses campagnes (asymétrie métier : la campagne est l'emprunteur du Lore,
* pas l'inverse). * 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 public record ChatRequest(
@Builder List<ChatMessage> messages,
public class ChatRequest { 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. */ /** Builder fluide : permet d'omettre les contextes non pertinents. */
LoreStructuralContext loreContext; 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). */ private Builder() {}
PageContext pageContext;
/** Optionnel : carte narrative d'une Campagne (chat Campagne uniquement). */ public Builder messages(List<ChatMessage> messages) {
CampaignStructuralContext campaignContext; this.messages = messages;
return this;
}
/** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */ public Builder loreContext(LoreStructuralContext loreContext) {
NarrativeEntityContext narrativeEntity; this.loreContext = loreContext;
return this;
}
/** public Builder pageContext(PageContext pageContext) {
* Optionnel : règles du système de JDR de la campagne (filtrées par intent). this.pageContext = pageContext;
* Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP. return this;
*/ }
GameSystemContext gameSystemContext;
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);
}
}
} }

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.Map; import java.util.Map;
/** /**
@@ -11,20 +8,14 @@ import java.util.Map;
* Contient uniquement les sections pertinentes pour l'intent de génération * Contient uniquement les sections pertinentes pour l'intent de génération
* en cours (sélection effectuée par GameSystemContextBuilder). Les sections * en cours (sélection effectuée par GameSystemContextBuilder). Les sections
* sont indexées par leur titre H2 original (ex : "Combat", "Classes"). * 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 public record GameSystemContext(
@Builder String systemName,
public class GameSystemContext { String systemDescription,
Map<String, String> sections) {
/** 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;
} }

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List; import java.util.List;
/** /**
@@ -10,19 +7,16 @@ import java.util.List;
* pour remplir une Page à partir d'un Template. * pour remplir une Page à partir d'un Template.
* <p> * <p>
* Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py). * Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py).
* Entité pure du domaine : aucune dépendance technique. * Record Java : pur domaine, aucune dépendance technique.
* <p> *
* Immuable via @Value (Lombok) : pas de setters, tous les champs final. * @param templateFields Champs à générer (clés attendues dans la réponse).
* C'est un DTO de domaine entrant dans le port AiProvider. * @param folderName Nom du LoreNode parent (ex: "PNJ", "Lieux").
*/ */
@Value public record GenerationContext(
@Builder String loreName,
public class GenerationContext { String loreDescription,
String folderName,
String loreName; String templateName,
String loreDescription; List<String> templateFields,
String folderName; // Nom du LoreNode parent (ex: "PNJ", "Lieux") String pageTitle) {
String templateName;
List<String> templateFields; // Champs à générer (clés attendues dans la réponse)
String pageTitle;
} }

View File

@@ -1,9 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -16,15 +12,14 @@ import java.util.Map;
* <p> * <p>
* La map `folders` est indexée par nom de dossier et mappe vers la liste * 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). * des pages qu'il contient (liste vide autorisée pour les dossiers vides).
* <p>
* Record Java : pur domaine, aucune dépendance technique.
*/ */
@Value public record LoreStructuralContext(
@Builder String loreName,
public class LoreStructuralContext { String loreDescription,
Map<String, List<PageSummary>> folders,
String loreName; List<String> tags) {
String loreDescription;
Map<String, List<PageSummary>> folders;
@Singular List<String> tags;
/** /**
* Résumé projeté d'une page pour l'IA. * 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 * uniquement ce qui est partageable en narration — les secrets MJ
* restent confinés à leur page d'édition). * restent confinés à leur page d'édition).
*/ */
@Value public record PageSummary(
@Builder String title,
public static class PageSummary { String templateName,
String title; Map<String, String> values,
String templateName; List<String> tags,
Map<String, String> values; List<String> relatedPageTitles) {
List<String> tags;
List<String> relatedPageTitles;
} }
} }

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.Map; import java.util.Map;
/** /**
@@ -17,13 +14,11 @@ import java.util.Map;
* `fields` associe le nom d'un champ (ex: "themes", "playerNarration") * `fields` associe le nom d'un champ (ex: "themes", "playerNarration")
* à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une * à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une
* LinkedHashMap à la construction pour un prompt lisible (ordre préservé). * LinkedHashMap à la construction pour un prompt lisible (ordre préservé).
*
* @param entityType "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt.
*/ */
@Value public record NarrativeEntityContext(
@Builder String entityType,
public class NarrativeEntityContext { String title,
Map<String, String> fields) {
/** "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt. */
String entityType;
String title;
Map<String, String> fields;
} }

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext; package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List; import java.util.List;
import java.util.Map; 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 * à l'IA de focaliser ses suggestions sur les bons champs sans déborder
* sur d'autres pages/templates. * sur d'autres pages/templates.
* <p> * <p>
* Object de valeur immuable, pur domaine aucune dépendance technique. * Record Java : immuable, pur domaine, aucune dépendance technique.
*/ */
@Value public record PageContext(
@Builder String title,
public class PageContext { String templateName,
List<String> templateFields,
String title; Map<String, String> values) {
String templateName;
List<String> templateFields;
Map<String, String> values;
} }

View File

@@ -53,12 +53,12 @@ public class BrainAiClient implements AiProvider {
private BrainGeneratePageRequest toBrainRequest(GenerationContext context) { private BrainGeneratePageRequest toBrainRequest(GenerationContext context) {
return new BrainGeneratePageRequest( return new BrainGeneratePageRequest(
context.getLoreName(), context.loreName(),
context.getLoreDescription(), context.loreDescription(),
context.getFolderName(), context.folderName(),
context.getTemplateName(), context.templateName(),
context.getTemplateFields(), context.templateFields(),
context.getPageTitle() context.pageTitle()
); );
} }

View File

@@ -38,35 +38,35 @@ public class BrainChatPayloadBuilder {
public Map<String, Object> build(ChatRequest request) { public Map<String, Object> build(ChatRequest request) {
Map<String, Object> root = new LinkedHashMap<>(); Map<String, Object> root = new LinkedHashMap<>();
root.put("messages", request.getMessages().stream() root.put("messages", request.messages().stream()
.map(this::messageToMap) .map(this::messageToMap)
.collect(Collectors.toList())); .collect(Collectors.toList()));
if (request.getLoreContext() != null) { if (request.loreContext() != null) {
root.put("lore_context", loreContextToMap(request.getLoreContext())); root.put("lore_context", loreContextToMap(request.loreContext()));
} }
if (request.getPageContext() != null) { if (request.pageContext() != null) {
root.put("page_context", pageContextToMap(request.getPageContext())); root.put("page_context", pageContextToMap(request.pageContext()));
} }
if (request.getCampaignContext() != null) { if (request.campaignContext() != null) {
root.put("campaign_context", campaignContextToMap(request.getCampaignContext())); root.put("campaign_context", campaignContextToMap(request.campaignContext()));
} }
if (request.getNarrativeEntity() != null) { if (request.narrativeEntity() != null) {
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity())); root.put("narrative_entity", narrativeEntityToMap(request.narrativeEntity()));
} }
if (request.getGameSystemContext() != null) { if (request.gameSystemContext() != null) {
root.put("game_system_context", gameSystemContextToMap(request.getGameSystemContext())); root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext()));
} }
return root; return root;
} }
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) { private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("system_name", gs.getSystemName()); map.put("system_name", gs.systemName());
if (gs.getSystemDescription() != null && !gs.getSystemDescription().isBlank()) { if (gs.systemDescription() != null && !gs.systemDescription().isBlank()) {
map.put("system_description", gs.getSystemDescription()); 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; return map;
} }
@@ -79,56 +79,56 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) { private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("lore_name", ctx.getLoreName()); map.put("lore_name", ctx.loreName());
map.put("lore_description", ctx.getLoreDescription()); map.put("lore_description", ctx.loreDescription());
Map<String, Object> foldersMap = new LinkedHashMap<>(); 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() foldersMap.put(e.getKey(), e.getValue().stream()
.map(this::pageSummaryToMap) .map(this::pageSummaryToMap)
.collect(Collectors.toList())); .collect(Collectors.toList()));
} }
map.put("folders", foldersMap); map.put("folders", foldersMap);
map.put("tags", ctx.getTags()); map.put("tags", ctx.tags());
return map; return map;
} }
private Map<String, Object> pageSummaryToMap(PageSummary ps) { private Map<String, Object> pageSummaryToMap(PageSummary ps) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("title", ps.getTitle()); map.put("title", ps.title());
map.put("template_name", ps.getTemplateName()); map.put("template_name", ps.templateName());
// values/tags/related_page_titles : omis si vides pour alléger le payload. // values/tags/related_page_titles : omis si vides pour alléger le payload.
if (ps.getValues() != null && !ps.getValues().isEmpty()) { if (ps.values() != null && !ps.values().isEmpty()) {
map.put("values", ps.getValues()); map.put("values", ps.values());
} }
if (ps.getTags() != null && !ps.getTags().isEmpty()) { if (ps.tags() != null && !ps.tags().isEmpty()) {
map.put("tags", ps.getTags()); map.put("tags", ps.tags());
} }
if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) { if (ps.relatedPageTitles() != null && !ps.relatedPageTitles().isEmpty()) {
map.put("related_page_titles", ps.getRelatedPageTitles()); map.put("related_page_titles", ps.relatedPageTitles());
} }
return map; return map;
} }
private Map<String, Object> pageContextToMap(PageContext pc) { private Map<String, Object> pageContextToMap(PageContext pc) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("title", pc.getTitle()); map.put("title", pc.title());
map.put("template_name", pc.getTemplateName()); map.put("template_name", pc.templateName());
map.put("template_fields", pc.getTemplateFields()); map.put("template_fields", pc.templateFields());
map.put("values", pc.getValues()); map.put("values", pc.values());
return map; return map;
} }
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) { private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("campaign_name", ctx.getCampaignName()); map.put("campaign_name", ctx.campaignName());
map.put("campaign_description", ctx.getCampaignDescription()); map.put("campaign_description", ctx.campaignDescription());
map.put("arcs", ctx.getArcs().stream() map.put("arcs", ctx.arcs().stream()
.map(this::arcSummaryToMap) .map(this::arcSummaryToMap)
.collect(Collectors.toList())); .collect(Collectors.toList()));
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches. // Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
if (ctx.getCharacters() != null && !ctx.getCharacters().isEmpty()) { if (ctx.characters() != null && !ctx.characters().isEmpty()) {
map.put("characters", ctx.getCharacters().stream() map.put("characters", ctx.characters().stream()
.map(this::characterSummaryToMap) .map(this::characterSummaryToMap)
.collect(Collectors.toList())); .collect(Collectors.toList()));
} }
@@ -137,9 +137,9 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> characterSummaryToMap(CharacterSummary c) { private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("name", c.getName()); map.put("name", c.name());
if (c.getSnippet() != null && !c.getSnippet().isBlank()) { if (c.snippet() != null && !c.snippet().isBlank()) {
map.put("snippet", c.getSnippet()); map.put("snippet", c.snippet());
} }
return map; return map;
} }
@@ -167,10 +167,10 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> arcSummaryToMap(ArcSummary a) { private Map<String, Object> arcSummaryToMap(ArcSummary a) {
return structuralSummaryToMap( return structuralSummaryToMap(
a, a,
ArcSummary::getName, ArcSummary::name,
ArcSummary::getDescription, ArcSummary::description,
ArcSummary::getIllustrationCount, ArcSummary::illustrationCount,
(map, arc) -> map.put("chapters", arc.getChapters().stream() (map, arc) -> map.put("chapters", arc.chapters().stream()
.map(this::chapterSummaryToMap) .map(this::chapterSummaryToMap)
.collect(Collectors.toList()))); .collect(Collectors.toList())));
} }
@@ -178,10 +178,10 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) { private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
return structuralSummaryToMap( return structuralSummaryToMap(
c, c,
ChapterSummary::getName, ChapterSummary::name,
ChapterSummary::getDescription, ChapterSummary::description,
ChapterSummary::getIllustrationCount, ChapterSummary::illustrationCount,
(map, chapter) -> map.put("scenes", chapter.getScenes().stream() (map, chapter) -> map.put("scenes", chapter.scenes().stream()
.map(this::sceneSummaryToMap) .map(this::sceneSummaryToMap)
.collect(Collectors.toList()))); .collect(Collectors.toList())));
} }
@@ -189,13 +189,13 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> sceneSummaryToMap(SceneSummary s) { private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
return structuralSummaryToMap( return structuralSummaryToMap(
s, s,
SceneSummary::getName, SceneSummary::name,
SceneSummary::getDescription, SceneSummary::description,
SceneSummary::getIllustrationCount, SceneSummary::illustrationCount,
(map, scene) -> { (map, scene) -> {
// Branches narratives : omises si absentes (scènes linéaires classiques). // Branches narratives : omises si absentes (scènes linéaires classiques).
if (s.getBranches() != null && !s.getBranches().isEmpty()) { if (s.branches() != null && !s.branches().isEmpty()) {
map.put("branches", s.getBranches().stream() map.put("branches", s.branches().stream()
.map(this::branchHintToMap) .map(this::branchHintToMap)
.collect(Collectors.toList())); .collect(Collectors.toList()));
} }
@@ -204,19 +204,19 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> branchHintToMap(BranchHint b) { private Map<String, Object> branchHintToMap(BranchHint b) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("label", b.getLabel()); map.put("label", b.label());
map.put("target_scene_name", b.getTargetSceneName()); map.put("target_scene_name", b.targetSceneName());
if (b.getCondition() != null && !b.getCondition().isBlank()) { if (b.condition() != null && !b.condition().isBlank()) {
map.put("condition", b.getCondition()); map.put("condition", b.condition());
} }
return map; return map;
} }
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) { private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("entity_type", ne.getEntityType()); map.put("entity_type", ne.entityType());
map.put("title", ne.getTitle()); map.put("title", ne.title());
map.put("fields", ne.getFields()); map.put("fields", ne.fields());
return map; return map;
} }
} }

View File

@@ -37,6 +37,9 @@ public class ArcJpaEntity {
@Column(name = "\"order\"", nullable = false) @Column(name = "\"order\"", nullable = false)
private int order; private int order;
@Column
private String icon;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update) // Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String themes; private String themes;

View File

@@ -37,6 +37,9 @@ public class ChapterJpaEntity {
@Column(name = "\"order\"", nullable = false) @Column(name = "\"order\"", nullable = false)
private int order; private int order;
@Column
private String icon;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update) // Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
@Column(name = "gm_notes", columnDefinition = "TEXT") @Column(name = "gm_notes", columnDefinition = "TEXT")
private String gmNotes; private String gmNotes;

View File

@@ -39,6 +39,9 @@ public class SceneJpaEntity {
@Column(name = "\"order\"", nullable = false) @Column(name = "\"order\"", nullable = false)
private int order; private int order;
@Column
private String icon;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update) // Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update)
// Contexte et ambiance // Contexte et ambiance

View File

@@ -71,6 +71,7 @@ public class PostgresArcRepository implements ArcRepository {
.description(jpaEntity.getDescription()) .description(jpaEntity.getDescription())
.campaignId(jpaEntity.getCampaignId().toString()) .campaignId(jpaEntity.getCampaignId().toString())
.order(jpaEntity.getOrder()) .order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.themes(jpaEntity.getThemes()) .themes(jpaEntity.getThemes())
.stakes(jpaEntity.getStakes()) .stakes(jpaEntity.getStakes())
.gmNotes(jpaEntity.getGmNotes()) .gmNotes(jpaEntity.getGmNotes())
@@ -99,6 +100,7 @@ public class PostgresArcRepository implements ArcRepository {
.description(arc.getDescription()) .description(arc.getDescription())
.campaignId(Long.parseLong(arc.getCampaignId())) .campaignId(Long.parseLong(arc.getCampaignId()))
.order(arc.getOrder()) .order(arc.getOrder())
.icon(arc.getIcon())
.themes(arc.getThemes()) .themes(arc.getThemes())
.stakes(arc.getStakes()) .stakes(arc.getStakes())
.gmNotes(arc.getGmNotes()) .gmNotes(arc.getGmNotes())

View File

@@ -71,6 +71,7 @@ public class PostgresChapterRepository implements ChapterRepository {
.description(jpaEntity.getDescription()) .description(jpaEntity.getDescription())
.arcId(jpaEntity.getArcId().toString()) .arcId(jpaEntity.getArcId().toString())
.order(jpaEntity.getOrder()) .order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.gmNotes(jpaEntity.getGmNotes()) .gmNotes(jpaEntity.getGmNotes())
.playerObjectives(jpaEntity.getPlayerObjectives()) .playerObjectives(jpaEntity.getPlayerObjectives())
.narrativeStakes(jpaEntity.getNarrativeStakes()) .narrativeStakes(jpaEntity.getNarrativeStakes())
@@ -96,6 +97,7 @@ public class PostgresChapterRepository implements ChapterRepository {
.description(chapter.getDescription()) .description(chapter.getDescription())
.arcId(Long.parseLong(chapter.getArcId())) .arcId(Long.parseLong(chapter.getArcId()))
.order(chapter.getOrder()) .order(chapter.getOrder())
.icon(chapter.getIcon())
.gmNotes(chapter.getGmNotes()) .gmNotes(chapter.getGmNotes())
.playerObjectives(chapter.getPlayerObjectives()) .playerObjectives(chapter.getPlayerObjectives())
.narrativeStakes(chapter.getNarrativeStakes()) .narrativeStakes(chapter.getNarrativeStakes())

View File

@@ -71,6 +71,7 @@ public class PostgresSceneRepository implements SceneRepository {
.description(jpaEntity.getDescription()) .description(jpaEntity.getDescription())
.chapterId(jpaEntity.getChapterId().toString()) .chapterId(jpaEntity.getChapterId().toString())
.order(jpaEntity.getOrder()) .order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.location(jpaEntity.getLocation()) .location(jpaEntity.getLocation())
.timing(jpaEntity.getTiming()) .timing(jpaEntity.getTiming())
.atmosphere(jpaEntity.getAtmosphere()) .atmosphere(jpaEntity.getAtmosphere())
@@ -104,6 +105,7 @@ public class PostgresSceneRepository implements SceneRepository {
.description(scene.getDescription()) .description(scene.getDescription())
.chapterId(Long.parseLong(scene.getChapterId())) .chapterId(Long.parseLong(scene.getChapterId()))
.order(scene.getOrder()) .order(scene.getOrder())
.icon(scene.getIcon())
.location(scene.getLocation()) .location(scene.getLocation())
.timing(scene.getTiming()) .timing(scene.getTiming())
.atmosphere(scene.getAtmosphere()) .atmosphere(scene.getAtmosphere())

View File

@@ -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) {}
}

View File

@@ -66,6 +66,7 @@ public class SecurityConfig {
// Preflight CORS toujours libre (le browser n'envoie pas Authorization sur OPTIONS) // Preflight CORS toujours libre (le browser n'envoie pas Authorization sur OPTIONS)
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/settings/**").hasRole("ADMIN") .requestMatchers("/api/settings/**").hasRole("ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().permitAll() .anyRequest().permitAll()
) )
.httpBasic(basic -> {}); .httpBasic(basic -> {});

View File

@@ -28,7 +28,7 @@ public class ArcController {
@PostMapping @PostMapping
public ResponseEntity<ArcDTO> createArc(@RequestBody ArcDTO arcDTO) { public ResponseEntity<ArcDTO> createArc(@RequestBody ArcDTO arcDTO) {
Arc arc = arcMapper.toDomain(arcDTO); Arc arc = arcMapper.toDomain(arcDTO);
Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder()); Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder(), arc.getIcon());
return ResponseEntity.ok(arcMapper.toDTO(createdArc)); return ResponseEntity.ok(arcMapper.toDTO(createdArc));
} }
@@ -40,17 +40,11 @@ public class ArcController {
} }
@GetMapping @GetMapping
public ResponseEntity<List<ArcDTO>> getAllArcs() { public ResponseEntity<List<ArcDTO>> getAllArcs(
List<Arc> arcs = arcService.getAllArcs(); @RequestParam(value = "campaignId", required = false) String campaignId) {
List<ArcDTO> arcDTOs = arcs.stream() List<Arc> arcs = (campaignId != null && !campaignId.isBlank())
.map(arcMapper::toDTO) ? arcService.getArcsByCampaignId(campaignId)
.collect(Collectors.toList()); : arcService.getAllArcs();
return ResponseEntity.ok(arcDTOs);
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<ArcDTO>> getArcsByCampaignId(@PathVariable String campaignId) {
List<Arc> arcs = arcService.getArcsByCampaignId(campaignId);
List<ArcDTO> arcDTOs = arcs.stream() List<ArcDTO> arcDTOs = arcs.stream()
.map(arcMapper::toDTO) .map(arcMapper::toDTO)
.collect(Collectors.toList()); .collect(Collectors.toList());

View File

@@ -28,7 +28,7 @@ public class ChapterController {
@PostMapping @PostMapping
public ResponseEntity<ChapterDTO> createChapter(@RequestBody ChapterDTO chapterDTO) { public ResponseEntity<ChapterDTO> createChapter(@RequestBody ChapterDTO chapterDTO) {
Chapter chapter = chapterMapper.toDomain(chapterDTO); Chapter chapter = chapterMapper.toDomain(chapterDTO);
Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder()); Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder(), chapter.getIcon());
return ResponseEntity.ok(chapterMapper.toDTO(createdChapter)); return ResponseEntity.ok(chapterMapper.toDTO(createdChapter));
} }
@@ -40,17 +40,11 @@ public class ChapterController {
} }
@GetMapping @GetMapping
public ResponseEntity<List<ChapterDTO>> getAllChapters() { public ResponseEntity<List<ChapterDTO>> getAllChapters(
List<Chapter> chapters = chapterService.getAllChapters(); @RequestParam(value = "arcId", required = false) String arcId) {
List<ChapterDTO> chapterDTOs = chapters.stream() List<Chapter> chapters = (arcId != null && !arcId.isBlank())
.map(chapterMapper::toDTO) ? chapterService.getChaptersByArcId(arcId)
.collect(Collectors.toList()); : chapterService.getAllChapters();
return ResponseEntity.ok(chapterDTOs);
}
@GetMapping("/arc/{arcId}")
public ResponseEntity<List<ChapterDTO>> getChaptersByArcId(@PathVariable String arcId) {
List<Chapter> chapters = chapterService.getChaptersByArcId(arcId);
List<ChapterDTO> chapterDTOs = chapters.stream() List<ChapterDTO> chapterDTOs = chapters.stream()
.map(chapterMapper::toDTO) .map(chapterMapper::toDTO)
.collect(Collectors.toList()); .collect(Collectors.toList());

View File

@@ -1,5 +1,6 @@
package com.loremind.infrastructure.web.controller; package com.loremind.infrastructure.web.controller;
import com.loremind.infrastructure.updates.UpdateCheckService;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -18,13 +19,18 @@ import java.util.Map;
public class ConfigController { public class ConfigController {
private final boolean demoMode; 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.demoMode = demoMode;
this.updates = updates;
} }
@GetMapping @GetMapping
public Map<String, Object> getPublicConfig() { public Map<String, Object> getPublicConfig() {
return Map.of("demoMode", demoMode); return Map.of(
"demoMode", demoMode,
"updateCheckEnabled", updates.isEnabled());
} }
} }

View File

@@ -28,7 +28,7 @@ public class SceneController {
@PostMapping @PostMapping
public ResponseEntity<SceneDTO> createScene(@RequestBody SceneDTO sceneDTO) { public ResponseEntity<SceneDTO> createScene(@RequestBody SceneDTO sceneDTO) {
Scene scene = sceneMapper.toDomain(sceneDTO); Scene scene = sceneMapper.toDomain(sceneDTO);
Scene createdScene = sceneService.createScene(scene.getName(), scene.getDescription(), scene.getChapterId(), scene.getOrder()); Scene createdScene = sceneService.createScene(scene.getName(), scene.getDescription(), scene.getChapterId(), scene.getOrder(), scene.getIcon());
return ResponseEntity.ok(sceneMapper.toDTO(createdScene)); return ResponseEntity.ok(sceneMapper.toDTO(createdScene));
} }
@@ -40,17 +40,11 @@ public class SceneController {
} }
@GetMapping @GetMapping
public ResponseEntity<List<SceneDTO>> getAllScenes() { public ResponseEntity<List<SceneDTO>> getAllScenes(
List<Scene> scenes = sceneService.getAllScenes(); @RequestParam(value = "chapterId", required = false) String chapterId) {
List<SceneDTO> sceneDTOs = scenes.stream() List<Scene> scenes = (chapterId != null && !chapterId.isBlank())
.map(sceneMapper::toDTO) ? sceneService.getScenesByChapterId(chapterId)
.collect(Collectors.toList()); : sceneService.getAllScenes();
return ResponseEntity.ok(sceneDTOs);
}
@GetMapping("/chapter/{chapterId}")
public ResponseEntity<List<SceneDTO>> getScenesByChapterId(@PathVariable String chapterId) {
List<Scene> scenes = sceneService.getScenesByChapterId(chapterId);
List<SceneDTO> sceneDTOs = scenes.stream() List<SceneDTO> sceneDTOs = scenes.stream()
.map(sceneMapper::toDTO) .map(sceneMapper::toDTO)
.collect(Collectors.toList()); .collect(Collectors.toList());

View File

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

View File

@@ -17,6 +17,9 @@ public class ArcDTO {
private String campaignId; private String campaignId;
private int order; private int order;
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis // Champs narratifs enrichis
private String themes; private String themes;
private String stakes; private String stakes;

View File

@@ -17,6 +17,9 @@ public class ChapterDTO {
private String arcId; private String arcId;
private int order; private int order;
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis // Champs narratifs enrichis
private String gmNotes; private String gmNotes;
private String playerObjectives; private String playerObjectives;

View File

@@ -17,6 +17,9 @@ public class SceneDTO {
private String chapterId; private String chapterId;
private int order; private int order;
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis // Champs narratifs enrichis
private String location; private String location;
private String timing; private String timing;

View File

@@ -24,6 +24,7 @@ public class ArcMapper {
dto.setDescription(arc.getDescription()); dto.setDescription(arc.getDescription());
dto.setCampaignId(arc.getCampaignId()); dto.setCampaignId(arc.getCampaignId());
dto.setOrder(arc.getOrder()); dto.setOrder(arc.getOrder());
dto.setIcon(arc.getIcon());
dto.setThemes(arc.getThemes()); dto.setThemes(arc.getThemes());
dto.setStakes(arc.getStakes()); dto.setStakes(arc.getStakes());
dto.setGmNotes(arc.getGmNotes()); dto.setGmNotes(arc.getGmNotes());
@@ -46,6 +47,7 @@ public class ArcMapper {
.description(dto.getDescription()) .description(dto.getDescription())
.campaignId(dto.getCampaignId()) .campaignId(dto.getCampaignId())
.order(dto.getOrder()) .order(dto.getOrder())
.icon(dto.getIcon())
.themes(dto.getThemes()) .themes(dto.getThemes())
.stakes(dto.getStakes()) .stakes(dto.getStakes())
.gmNotes(dto.getGmNotes()) .gmNotes(dto.getGmNotes())

View File

@@ -24,6 +24,7 @@ public class ChapterMapper {
dto.setDescription(chapter.getDescription()); dto.setDescription(chapter.getDescription());
dto.setArcId(chapter.getArcId()); dto.setArcId(chapter.getArcId());
dto.setOrder(chapter.getOrder()); dto.setOrder(chapter.getOrder());
dto.setIcon(chapter.getIcon());
dto.setGmNotes(chapter.getGmNotes()); dto.setGmNotes(chapter.getGmNotes());
dto.setPlayerObjectives(chapter.getPlayerObjectives()); dto.setPlayerObjectives(chapter.getPlayerObjectives());
dto.setNarrativeStakes(chapter.getNarrativeStakes()); dto.setNarrativeStakes(chapter.getNarrativeStakes());
@@ -44,6 +45,7 @@ public class ChapterMapper {
.description(dto.getDescription()) .description(dto.getDescription())
.arcId(dto.getArcId()) .arcId(dto.getArcId())
.order(dto.getOrder()) .order(dto.getOrder())
.icon(dto.getIcon())
.gmNotes(dto.getGmNotes()) .gmNotes(dto.getGmNotes())
.playerObjectives(dto.getPlayerObjectives()) .playerObjectives(dto.getPlayerObjectives())
.narrativeStakes(dto.getNarrativeStakes()) .narrativeStakes(dto.getNarrativeStakes())

View File

@@ -27,6 +27,7 @@ public class SceneMapper {
dto.setDescription(scene.getDescription()); dto.setDescription(scene.getDescription());
dto.setChapterId(scene.getChapterId()); dto.setChapterId(scene.getChapterId());
dto.setOrder(scene.getOrder()); dto.setOrder(scene.getOrder());
dto.setIcon(scene.getIcon());
dto.setLocation(scene.getLocation()); dto.setLocation(scene.getLocation());
dto.setTiming(scene.getTiming()); dto.setTiming(scene.getTiming());
dto.setAtmosphere(scene.getAtmosphere()); dto.setAtmosphere(scene.getAtmosphere());
@@ -59,6 +60,7 @@ public class SceneMapper {
.description(dto.getDescription()) .description(dto.getDescription())
.chapterId(dto.getChapterId()) .chapterId(dto.getChapterId())
.order(dto.getOrder()) .order(dto.getOrder())
.icon(dto.getIcon())
.location(dto.getLocation()) .location(dto.getLocation())
.timing(dto.getTiming()) .timing(dto.getTiming())
.atmosphere(dto.getAtmosphere()) .atmosphere(dto.getAtmosphere())
@@ -85,18 +87,14 @@ public class SceneMapper {
private List<SceneBranchDTO> toBranchDTOs(List<SceneBranch> branches) { private List<SceneBranchDTO> toBranchDTOs(List<SceneBranch> branches) {
if (branches == null) return new ArrayList<>(); if (branches == null) return new ArrayList<>();
return branches.stream() 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()); .collect(Collectors.toList());
} }
private List<SceneBranch> toBranchDomain(List<SceneBranchDTO> dtos) { private List<SceneBranch> toBranchDomain(List<SceneBranchDTO> dtos) {
if (dtos == null) return new ArrayList<>(); if (dtos == null) return new ArrayList<>();
return dtos.stream() return dtos.stream()
.map(d -> SceneBranch.builder() .map(d -> new SceneBranch(d.getLabel(), d.getTargetSceneId(), d.getCondition()))
.label(d.getLabel())
.targetSceneId(d.getTargetSceneId())
.condition(d.getCondition())
.build())
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
} }

View File

@@ -54,3 +54,11 @@ spring.servlet.multipart.max-request-size=10MB
# Mode demo : masque Settings/Export cote front et bloque les PUT /api/settings # Mode demo : masque Settings/Export cote front et bloque les PUT /api/settings
# cote serveur. Activer via DEMO_MODE=true sur les deploiements publics. # cote serveur. Activer via DEMO_MODE=true sur les deploiements publics.
app.demo-mode=${DEMO_MODE:false} 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:}

View File

@@ -178,10 +178,7 @@ public class SceneServiceTest {
@Test @Test
void testUpdateScene_WithValidBranches() { void testUpdateScene_WithValidBranches() {
// Arrange // Arrange
SceneBranch branch = SceneBranch.builder() SceneBranch branch = SceneBranch.of("Go to scene 2", "scene-2");
.targetSceneId("scene-2")
.label("Go to scene 2")
.build();
Scene updatedScene = Scene.builder() Scene updatedScene = Scene.builder()
.name("Updated Scene") .name("Updated Scene")
.branches(List.of(branch)) .branches(List.of(branch))
@@ -203,10 +200,7 @@ public class SceneServiceTest {
@Test @Test
void testUpdateScene_WithBranchToSelf() { void testUpdateScene_WithBranchToSelf() {
// Arrange // Arrange
SceneBranch branch = SceneBranch.builder() SceneBranch branch = SceneBranch.of("Self-reference", "scene-1");
.targetSceneId("scene-1")
.label("Self-reference")
.build();
Scene updatedScene = Scene.builder() Scene updatedScene = Scene.builder()
.name("Updated Scene") .name("Updated Scene")
.branches(List.of(branch)) .branches(List.of(branch))
@@ -228,10 +222,7 @@ public class SceneServiceTest {
@Test @Test
void testUpdateScene_WithBranchToDifferentChapter() { void testUpdateScene_WithBranchToDifferentChapter() {
// Arrange // Arrange
SceneBranch branch = SceneBranch.builder() SceneBranch branch = SceneBranch.of("Go to other chapter", "scene-other-chapter");
.targetSceneId("scene-other-chapter")
.label("Go to other chapter")
.build();
Scene updatedScene = Scene.builder() Scene updatedScene = Scene.builder()
.name("Updated Scene") .name("Updated Scene")
.branches(List.of(branch)) .branches(List.of(branch))
@@ -253,10 +244,7 @@ public class SceneServiceTest {
@Test @Test
void testUpdateScene_WithBranchNullTarget() { void testUpdateScene_WithBranchNullTarget() {
// Arrange // Arrange
SceneBranch branch = SceneBranch.builder() SceneBranch branch = SceneBranch.of("Null target", null);
.targetSceneId(null)
.label("Null target")
.build();
Scene updatedScene = Scene.builder() Scene updatedScene = Scene.builder()
.name("Updated Scene") .name("Updated Scene")
.branches(List.of(branch)) .branches(List.of(branch))
@@ -277,10 +265,7 @@ public class SceneServiceTest {
@Test @Test
void testUpdateScene_WithBranchBlankTarget() { void testUpdateScene_WithBranchBlankTarget() {
// Arrange // Arrange
SceneBranch branch = SceneBranch.builder() SceneBranch branch = SceneBranch.of("Blank target", " ");
.targetSceneId(" ")
.label("Blank target")
.build();
Scene updatedScene = Scene.builder() Scene updatedScene = Scene.builder()
.name("Updated Scene") .name("Updated Scene")
.branches(List.of(branch)) .branches(List.of(branch))

View File

@@ -74,9 +74,9 @@ public class CampaignStructuralContextBuilderTest {
CampaignStructuralContext ctx = builder.build("camp-1"); CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals("Les Terres Brisées", ctx.getCampaignName()); assertEquals("Les Terres Brisées", ctx.campaignName());
assertEquals("Campagne dark fantasy", ctx.getCampaignDescription()); assertEquals("Campagne dark fantasy", ctx.campaignDescription());
assertTrue(ctx.getArcs().isEmpty()); assertTrue(ctx.arcs().isEmpty());
} }
@Test @Test
@@ -100,19 +100,19 @@ public class CampaignStructuralContextBuilderTest {
CampaignStructuralContext ctx = builder.build("camp-1"); CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals(2, ctx.getArcs().size()); assertEquals(2, ctx.arcs().size());
assertEquals("Arc A", ctx.getArcs().get(0).getName()); assertEquals("Arc A", ctx.arcs().get(0).name());
assertEquals("Arc B", ctx.getArcs().get(1).getName()); assertEquals("Arc B", ctx.arcs().get(1).name());
// Chapitres tries : ch2 (order 1) avant ch1 (order 2) // Chapitres tries : ch2 (order 1) avant ch1 (order 2)
assertEquals(2, ctx.getArcs().get(0).getChapters().size()); assertEquals(2, ctx.arcs().get(0).chapters().size());
assertEquals("Ch B", ctx.getArcs().get(0).getChapters().get(0).getName()); assertEquals("Ch B", ctx.arcs().get(0).chapters().get(0).name());
assertEquals("Ch A", ctx.getArcs().get(0).getChapters().get(1).getName()); assertEquals("Ch A", ctx.arcs().get(0).chapters().get(1).name());
// Scenes dans ch-1 : s2 (order 1) avant s1 (order 2) // Scenes dans ch-1 : s2 (order 1) avant s1 (order 2)
var chADto = ctx.getArcs().get(0).getChapters().get(1); var chADto = ctx.arcs().get(0).chapters().get(1);
assertEquals("Scene B", chADto.getScenes().get(0).getName()); assertEquals("Scene B", chADto.scenes().get(0).name());
assertEquals("Scene A", chADto.getScenes().get(1).getName()); assertEquals("Scene A", chADto.scenes().get(1).name());
} }
@Test @Test
@@ -120,15 +120,8 @@ public class CampaignStructuralContextBuilderTest {
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1).build(); 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(); Chapter ch = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch").description("").order(1).build();
SceneBranch validBranch = SceneBranch.builder() SceneBranch validBranch = new SceneBranch("Si les joueurs fuient", "s-2", "en cas de combat perdu");
.label("Si les joueurs fuient") SceneBranch danglingBranch = SceneBranch.of("Vers l'inconnu", "s-inconnu");
.targetSceneId("s-2")
.condition("en cas de combat perdu")
.build();
SceneBranch danglingBranch = SceneBranch.builder()
.label("Vers l'inconnu")
.targetSceneId("s-inconnu")
.build();
Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Entrée").description("") Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Entrée").description("")
.order(1) .order(1)
@@ -143,12 +136,12 @@ public class CampaignStructuralContextBuilderTest {
CampaignStructuralContext ctx = builder.build("camp-1"); CampaignStructuralContext ctx = builder.build("camp-1");
var scene1Summary = ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0); var scene1Summary = ctx.arcs().get(0).chapters().get(0).scenes().get(0);
assertEquals(2, scene1Summary.getBranches().size()); assertEquals(2, scene1Summary.branches().size());
assertEquals("Fuite", scene1Summary.getBranches().get(0).getTargetSceneName()); assertEquals("Fuite", scene1Summary.branches().get(0).targetSceneName());
assertEquals("en cas de combat perdu", scene1Summary.getBranches().get(0).getCondition()); assertEquals("en cas de combat perdu", scene1Summary.branches().get(0).condition());
// ID inconnu → libellé de fallback // ID inconnu → libellé de fallback
assertEquals("(scène inconnue)", scene1Summary.getBranches().get(1).getTargetSceneName()); assertEquals("(scène inconnue)", scene1Summary.branches().get(1).targetSceneName());
} }
@Test @Test
@@ -170,9 +163,9 @@ public class CampaignStructuralContextBuilderTest {
CampaignStructuralContext ctx = builder.build("camp-1"); CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals(2, ctx.getArcs().get(0).getIllustrationCount()); assertEquals(2, ctx.arcs().get(0).illustrationCount());
assertEquals(0, ctx.getArcs().get(0).getChapters().get(0).getIllustrationCount()); assertEquals(0, ctx.arcs().get(0).chapters().get(0).illustrationCount());
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getIllustrationCount()); assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().get(0).illustrationCount());
assertTrue(ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().isEmpty()); assertTrue(ctx.arcs().get(0).chapters().get(0).scenes().get(0).branches().isEmpty());
} }
} }

View File

@@ -89,13 +89,13 @@ public class GeneratePageValuesUseCaseTest {
verify(aiProvider).generatePage(captor.capture()); verify(aiProvider).generatePage(captor.capture());
GenerationContext ctx = captor.getValue(); GenerationContext ctx = captor.getValue();
assertEquals("Aetheria", ctx.getLoreName()); assertEquals("Aetheria", ctx.loreName());
assertEquals("monde aérien", ctx.getLoreDescription()); assertEquals("monde aérien", ctx.loreDescription());
assertEquals("PNJ", ctx.getFolderName()); assertEquals("PNJ", ctx.folderName());
assertEquals("Personnage", ctx.getTemplateName()); assertEquals("Personnage", ctx.templateName());
assertEquals("Alice", ctx.getPageTitle()); assertEquals("Alice", ctx.pageTitle());
// Seuls les champs TEXT doivent etre envoyes (pas "Portrait" IMAGE). // 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 @Test

View File

@@ -71,10 +71,10 @@ public class LoreStructuralContextBuilderTest {
LoreStructuralContext ctx = builder.build("lore-1"); LoreStructuralContext ctx = builder.build("lore-1");
assertEquals("Aetheria", ctx.getLoreName()); assertEquals("Aetheria", ctx.loreName());
assertEquals("Monde aérien", ctx.getLoreDescription()); assertEquals("Monde aérien", ctx.loreDescription());
assertTrue(ctx.getFolders().isEmpty()); assertTrue(ctx.folders().isEmpty());
assertTrue(ctx.getTags().isEmpty()); assertTrue(ctx.tags().isEmpty());
} }
@Test @Test
@@ -110,32 +110,32 @@ public class LoreStructuralContextBuilderTest {
LoreStructuralContext ctx = builder.build("lore-1"); LoreStructuralContext ctx = builder.build("lore-1");
assertEquals(2, ctx.getFolders().size()); assertEquals(2, ctx.folders().size());
assertTrue(ctx.getFolders().containsKey("PNJ")); assertTrue(ctx.folders().containsKey("PNJ"));
assertTrue(ctx.getFolders().containsKey("Lieux")); assertTrue(ctx.folders().containsKey("Lieux"));
var pnjPages = ctx.getFolders().get("PNJ"); var pnjPages = ctx.folders().get("PNJ");
assertEquals(1, pnjPages.size()); assertEquals(1, pnjPages.size());
var aliceSummary = pnjPages.get(0); var aliceSummary = pnjPages.get(0);
assertEquals("Alice", aliceSummary.getTitle()); assertEquals("Alice", aliceSummary.title());
assertEquals("Personnage", aliceSummary.getTemplateName()); assertEquals("Personnage", aliceSummary.templateName());
// Blank/null filtrés // Blank/null filtrés
assertEquals(1, aliceSummary.getValues().size()); assertEquals(1, aliceSummary.values().size());
assertEquals("Il était une fois...", aliceSummary.getValues().get("Histoire")); assertEquals("Il était une fois...", aliceSummary.values().get("Histoire"));
assertEquals(List.of("hero", "magic"), aliceSummary.getTags()); assertEquals(List.of("hero", "magic"), aliceSummary.tags());
// p-2 resolved into title, p-ghost dropped silently // 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 → "?" // Template introuvable → "?"
assertEquals("?", forestSummary.getTemplateName()); assertEquals("?", forestSummary.templateName());
assertTrue(forestSummary.getValues().isEmpty()); assertTrue(forestSummary.values().isEmpty());
assertTrue(forestSummary.getRelatedPageTitles().isEmpty()); assertTrue(forestSummary.relatedPageTitles().isEmpty());
// Tags uniques entre les 2 pages // Tags uniques entre les 2 pages
assertEquals(2, ctx.getTags().size()); assertEquals(2, ctx.tags().size());
assertTrue(ctx.getTags().contains("hero")); assertTrue(ctx.tags().contains("hero"));
assertTrue(ctx.getTags().contains("magic")); assertTrue(ctx.tags().contains("magic"));
} }
@Test @Test
@@ -160,7 +160,7 @@ public class LoreStructuralContextBuilderTest {
LoreStructuralContext ctx = builder.build("lore-1"); 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); assertNotNull(truncated);
assertEquals(500 + 1, truncated.length()); // 500 + ellipse assertEquals(500 + 1, truncated.length()); // 500 + ellipse
assertTrue(truncated.endsWith("")); assertTrue(truncated.endsWith(""));
@@ -185,9 +185,9 @@ public class LoreStructuralContextBuilderTest {
LoreStructuralContext ctx = builder.build("lore-1"); LoreStructuralContext ctx = builder.build("lore-1");
var summary = ctx.getFolders().get("PNJ").get(0); var summary = ctx.folders().get("PNJ").get(0);
assertTrue(summary.getValues().isEmpty()); assertTrue(summary.values().isEmpty());
assertTrue(summary.getTags().isEmpty()); assertTrue(summary.tags().isEmpty());
assertTrue(summary.getRelatedPageTitles().isEmpty()); assertTrue(summary.relatedPageTitles().isEmpty());
} }
} }

View File

@@ -44,14 +44,14 @@ public class NarrativeEntityContextBuilderTest {
NarrativeEntityContext ctx = builder.build("arc", "arc-1"); NarrativeEntityContext ctx = builder.build("arc", "arc-1");
assertEquals("arc", ctx.getEntityType()); assertEquals("arc", ctx.entityType());
assertEquals("L'arc sombre", ctx.getTitle()); assertEquals("L'arc sombre", ctx.title());
assertEquals("synopsis", ctx.getFields().get("description (synopsis)")); assertEquals("synopsis", ctx.fields().get("description (synopsis)"));
assertEquals("trahison", ctx.getFields().get("themes")); assertEquals("trahison", ctx.fields().get("themes"));
assertEquals("vie ou mort", ctx.getFields().get("stakes")); assertEquals("vie ou mort", ctx.fields().get("stakes"));
assertEquals("pouvoir", ctx.getFields().get("rewards")); assertEquals("pouvoir", ctx.fields().get("rewards"));
assertEquals("le roi meurt", ctx.getFields().get("resolution")); assertEquals("le roi meurt", ctx.fields().get("resolution"));
assertEquals("secret", ctx.getFields().get("gmNotes")); assertEquals("secret", ctx.fields().get("gmNotes"));
} }
@Test @Test
@@ -64,12 +64,12 @@ public class NarrativeEntityContextBuilderTest {
NarrativeEntityContext ctx = builder.build("chapter", "ch-1"); NarrativeEntityContext ctx = builder.build("chapter", "ch-1");
assertEquals("chapter", ctx.getEntityType()); assertEquals("chapter", ctx.entityType());
assertEquals("Chapitre 1", ctx.getTitle()); assertEquals("Chapitre 1", ctx.title());
assertEquals("", ctx.getFields().get("description (synopsis)")); assertEquals("", ctx.fields().get("description (synopsis)"));
assertEquals("", ctx.getFields().get("playerObjectives")); assertEquals("", ctx.fields().get("playerObjectives"));
assertEquals("haut", ctx.getFields().get("narrativeStakes")); assertEquals("haut", ctx.fields().get("narrativeStakes"));
assertEquals("", ctx.getFields().get("gmNotes")); assertEquals("", ctx.fields().get("gmNotes"));
} }
@Test @Test
@@ -85,17 +85,17 @@ public class NarrativeEntityContextBuilderTest {
NarrativeEntityContext ctx = builder.build("scene", "s-1"); NarrativeEntityContext ctx = builder.build("scene", "s-1");
assertEquals("scene", ctx.getEntityType()); assertEquals("scene", ctx.entityType());
assertEquals("L'auberge", ctx.getTitle()); assertEquals("L'auberge", ctx.title());
assertEquals("lieu calme", ctx.getFields().get("description")); assertEquals("lieu calme", ctx.fields().get("description"));
assertEquals("Taverne", ctx.getFields().get("location")); assertEquals("Taverne", ctx.fields().get("location"));
assertEquals("Soir", ctx.getFields().get("timing")); assertEquals("Soir", ctx.fields().get("timing"));
assertEquals("tendue", ctx.getFields().get("atmosphere")); assertEquals("tendue", ctx.fields().get("atmosphere"));
assertEquals("Vous entrez...", ctx.getFields().get("playerNarration")); assertEquals("Vous entrez...", ctx.fields().get("playerNarration"));
assertEquals("option A...", ctx.getFields().get("choicesConsequences")); assertEquals("option A...", ctx.fields().get("choicesConsequences"));
assertEquals("moyen", ctx.getFields().get("combatDifficulty")); assertEquals("moyen", ctx.fields().get("combatDifficulty"));
assertEquals("3 bandits", ctx.getFields().get("enemies")); assertEquals("3 bandits", ctx.fields().get("enemies"));
assertEquals("trésor caché", ctx.getFields().get("gmSecretNotes")); assertEquals("trésor caché", ctx.fields().get("gmSecretNotes"));
} }
@Test @Test
@@ -104,7 +104,7 @@ public class NarrativeEntityContextBuilderTest {
when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc)); when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc));
NarrativeEntityContext ctx = builder.build(" ARC ", "arc-1"); NarrativeEntityContext ctx = builder.build(" ARC ", "arc-1");
assertEquals("arc", ctx.getEntityType()); assertEquals("arc", ctx.entityType());
} }
@Test @Test

View File

@@ -55,9 +55,7 @@ public class StreamChatForCampaignUseCaseTest {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@BeforeEach @BeforeEach
void setUp() { void setUp() {
campaignCtx = CampaignStructuralContext.builder() campaignCtx = new CampaignStructuralContext("X", "d", List.of(), List.of());
.campaignName("X").campaignDescription("d")
.build();
messages = List.of(); messages = List.of();
onUsage = mock(Consumer.class); onUsage = mock(Consumer.class);
onToken = mock(Consumer.class); onToken = mock(Consumer.class);
@@ -85,10 +83,10 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError)); verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
ChatRequest req = captor.getValue(); ChatRequest req = captor.getValue();
assertSame(campaignCtx, req.getCampaignContext()); assertSame(campaignCtx, req.campaignContext());
assertNull(req.getLoreContext()); assertNull(req.loreContext());
assertNull(req.getNarrativeEntity()); assertNull(req.narrativeEntity());
assertNull(req.getPageContext()); assertNull(req.pageContext());
verifyNoInteractions(loreContextBuilder); verifyNoInteractions(loreContextBuilder);
verifyNoInteractions(narrativeEntityContextBuilder); verifyNoInteractions(narrativeEntityContextBuilder);
} }
@@ -96,8 +94,8 @@ public class StreamChatForCampaignUseCaseTest {
@Test @Test
void testExecute_LinkedCampaign_LoadsLoreContext() { void testExecute_LinkedCampaign_LoadsLoreContext() {
Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-1").build(); Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-1").build();
LoreStructuralContext loreCtx = LoreStructuralContext.builder() LoreStructuralContext loreCtx = new LoreStructuralContext(
.loreName("L").loreDescription("d").folders(Collections.emptyMap()).build(); "L", "d", Collections.emptyMap(), List.of());
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked)); when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx); when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
@@ -107,7 +105,7 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any()); verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertSame(loreCtx, captor.getValue().getLoreContext()); assertSame(loreCtx, captor.getValue().loreContext());
} }
@Test @Test
@@ -122,15 +120,14 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any()); 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). // La requete doit tout de meme partir (pas d'exception).
} }
@Test @Test
void testExecute_WithEntityFocus_BuildsNarrativeEntity() { void testExecute_WithEntityFocus_BuildsNarrativeEntity() {
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build(); Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
NarrativeEntityContext entity = NarrativeEntityContext.builder() NarrativeEntityContext entity = new NarrativeEntityContext("scene", "L'auberge", Map.of());
.entityType("scene").title("L'auberge").fields(Map.of()).build();
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone)); when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx); when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
@@ -140,7 +137,7 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any()); verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertSame(entity, captor.getValue().getNarrativeEntity()); assertSame(entity, captor.getValue().narrativeEntity());
} }
@Test @Test
@@ -153,7 +150,7 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any()); verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertNull(captor.getValue().getNarrativeEntity()); assertNull(captor.getValue().narrativeEntity());
verifyNoInteractions(narrativeEntityContextBuilder); verifyNoInteractions(narrativeEntityContextBuilder);
} }
} }

View File

@@ -55,10 +55,7 @@ public class StreamChatForLoreUseCaseTest {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@BeforeEach @BeforeEach
void setUp() { void setUp() {
loreCtx = LoreStructuralContext.builder() loreCtx = new LoreStructuralContext("Aetheria", "d", Collections.emptyMap(), List.of());
.loreName("Aetheria").loreDescription("d")
.folders(Collections.emptyMap())
.build();
messages = List.of(); messages = List.of();
onUsage = mock(Consumer.class); onUsage = mock(Consumer.class);
onToken = mock(Consumer.class); onToken = mock(Consumer.class);
@@ -75,9 +72,9 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError)); verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
ChatRequest req = captor.getValue(); ChatRequest req = captor.getValue();
assertSame(loreCtx, req.getLoreContext()); assertSame(loreCtx, req.loreContext());
assertNull(req.getPageContext()); assertNull(req.pageContext());
assertNull(req.getCampaignContext()); assertNull(req.campaignContext());
} }
@Test @Test
@@ -88,7 +85,7 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any()); verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertNull(captor.getValue().getPageContext()); assertNull(captor.getValue().pageContext());
verifyNoInteractions(pageRepository); verifyNoInteractions(pageRepository);
} }
@@ -116,12 +113,12 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any()); verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
ChatRequest req = captor.getValue(); ChatRequest req = captor.getValue();
assertNotNull(req.getPageContext()); assertNotNull(req.pageContext());
assertEquals("Alice", req.getPageContext().getTitle()); assertEquals("Alice", req.pageContext().title());
assertEquals("Personnage", req.getPageContext().getTemplateName()); assertEquals("Personnage", req.pageContext().templateName());
// Seuls les champs TEXT exposes // Seuls les champs TEXT exposes
assertEquals(List.of("Histoire"), req.getPageContext().getTemplateFields()); assertEquals(List.of("Histoire"), req.pageContext().templateFields());
assertEquals(values, req.getPageContext().getValues()); assertEquals(values, req.pageContext().values());
} }
@Test @Test
@@ -137,12 +134,12 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any()); verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
var pageCtx = captor.getValue().getPageContext(); var pageCtx = captor.getValue().pageContext();
assertNotNull(pageCtx); assertNotNull(pageCtx);
assertEquals("Orphan", pageCtx.getTitle()); assertEquals("Orphan", pageCtx.title());
assertEquals("?", pageCtx.getTemplateName()); assertEquals("?", pageCtx.templateName());
assertTrue(pageCtx.getTemplateFields().isEmpty()); assertTrue(pageCtx.templateFields().isEmpty());
assertTrue(pageCtx.getValues().isEmpty()); assertTrue(pageCtx.values().isEmpty());
verifyNoInteractions(templateRepository); verifyNoInteractions(templateRepository);
} }
@@ -160,9 +157,9 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class); ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any()); verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
var pageCtx = captor.getValue().getPageContext(); var pageCtx = captor.getValue().pageContext();
assertEquals("?", pageCtx.getTemplateName()); assertEquals("?", pageCtx.templateName());
assertTrue(pageCtx.getTemplateFields().isEmpty()); assertTrue(pageCtx.templateFields().isEmpty());
} }
@Test @Test

View File

@@ -9,48 +9,36 @@ import static org.junit.jupiter.api.Assertions.assertNull;
/** /**
* Tests unitaires pour SceneBranch (Value Object). * Tests unitaires pour SceneBranch (Value Object).
* Verifie : * Verifie :
* - l'immuabilite (pas de setters : seul le builder permet la construction), * - l'immuabilite (record : aucun setter, constructeur canonique uniquement),
* - l'egalite structurelle generee par @Value (equals/hashCode sur tous les * - l'egalite structurelle generee par record (equals/hashCode sur tous les
* champs) — deux branches aux memes champs sont strictement egales, * champs) — deux branches aux memes champs sont strictement egales,
* - le support du champ optionnel {@code condition}. * - le support du champ optionnel {@code condition}.
*/ */
class SceneBranchTest { class SceneBranchTest {
@Test @Test
void builder_exposesAllFields() { void constructor_exposesAllFields() {
SceneBranch branch = SceneBranch.builder() SceneBranch branch = new SceneBranch(
.label("Si les joueurs attaquent le garde") "Si les joueurs attaquent le garde",
.targetSceneId("sc-combat") "sc-combat",
.condition("initiative > 15") "initiative > 15");
.build();
assertEquals("Si les joueurs attaquent le garde", branch.getLabel()); assertEquals("Si les joueurs attaquent le garde", branch.label());
assertEquals("sc-combat", branch.getTargetSceneId()); assertEquals("sc-combat", branch.targetSceneId());
assertEquals("initiative > 15", branch.getCondition()); assertEquals("initiative > 15", branch.condition());
} }
@Test @Test
void condition_isOptional() { void condition_isOptional() {
SceneBranch branch = SceneBranch.builder() SceneBranch branch = SceneBranch.of("sortie par la porte", "sc-corridor");
.label("sortie par la porte")
.targetSceneId("sc-corridor")
.build();
assertNull(branch.getCondition()); assertNull(branch.condition());
} }
@Test @Test
void twoBranches_withSameFields_areEqual() { void twoBranches_withSameFields_areEqual() {
SceneBranch a = SceneBranch.builder() SceneBranch a = new SceneBranch("fuite", "sc-2", null);
.label("fuite") SceneBranch b = new SceneBranch("fuite", "sc-2", null);
.targetSceneId("sc-2")
.condition(null)
.build();
SceneBranch b = SceneBranch.builder()
.label("fuite")
.targetSceneId("sc-2")
.condition(null)
.build();
assertEquals(a, b); assertEquals(a, b);
assertEquals(a.hashCode(), b.hashCode()); assertEquals(a.hashCode(), b.hashCode());
@@ -58,16 +46,16 @@ class SceneBranchTest {
@Test @Test
void twoBranches_differingOnTargetSceneId_areNotEqual() { void twoBranches_differingOnTargetSceneId_areNotEqual() {
SceneBranch a = SceneBranch.builder().label("X").targetSceneId("sc-1").build(); SceneBranch a = SceneBranch.of("X", "sc-1");
SceneBranch b = SceneBranch.builder().label("X").targetSceneId("sc-2").build(); SceneBranch b = SceneBranch.of("X", "sc-2");
assertNotEquals(a, b); assertNotEquals(a, b);
} }
@Test @Test
void twoBranches_differingOnCondition_areNotEqual() { void twoBranches_differingOnCondition_areNotEqual() {
SceneBranch a = SceneBranch.builder().label("X").targetSceneId("sc-1").condition("A").build(); SceneBranch a = new SceneBranch("X", "sc-1", "A");
SceneBranch b = SceneBranch.builder().label("X").targetSceneId("sc-1").condition("B").build(); SceneBranch b = new SceneBranch("X", "sc-1", "B");
assertNotEquals(a, b); assertNotEquals(a, b);
} }

View File

@@ -60,15 +60,15 @@ class SceneTest {
@Test @Test
void builder_preservesBranches_whenProvided() { void builder_preservesBranches_whenProvided() {
SceneBranch b1 = SceneBranch.builder().label("fuite").targetSceneId("sc-2").build(); SceneBranch b1 = SceneBranch.of("fuite", "sc-2");
SceneBranch b2 = SceneBranch.builder().label("combat").targetSceneId("sc-3").build(); SceneBranch b2 = SceneBranch.of("combat", "sc-3");
Scene scene = Scene.builder() Scene scene = Scene.builder()
.branches(List.of(b1, b2)) .branches(List.of(b1, b2))
.build(); .build();
assertEquals(2, scene.getBranches().size()); assertEquals(2, scene.getBranches().size());
assertEquals("fuite", scene.getBranches().get(0).getLabel()); assertEquals("fuite", scene.getBranches().get(0).label());
assertEquals("sc-3", scene.getBranches().get(1).getTargetSceneId()); assertEquals("sc-3", scene.getBranches().get(1).targetSceneId());
} }
} }

View File

@@ -6,108 +6,97 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSu
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary; import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import org.junit.jupiter.api.Test; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** /**
* Tests unitaires pour CampaignStructuralContext et ses types imbriques. * Tests unitaires pour CampaignStructuralContext et ses types imbriques.
* Focus sur les annotations @Singular (chapters/scenes/branches/arcs) qui * Records purs : aucune dependance technique.
* permettent une construction incrementale du graphe narratif.
*/ */
class CampaignStructuralContextTest { class CampaignStructuralContextTest {
@Test @Test
void builder_constructsFullNarrativeTree() { void constructor_buildsFullNarrativeTree() {
BranchHint branch = BranchHint.builder() BranchHint branch = new BranchHint("si les PJ fuient", "La poursuite", "PJ < moitie des HP");
.label("si les PJ fuient")
.targetSceneName("La poursuite")
.condition("PJ < moitie des HP")
.build();
SceneSummary scene = SceneSummary.builder() SceneSummary scene = new SceneSummary(
.name("L'auberge") "L'auberge",
.description("Rencontre tendue avec le tavernier") "Rencontre tendue avec le tavernier",
.illustrationCount(2) 2,
.branch(branch) List.of(branch));
.build();
ChapterSummary chapter = ChapterSummary.builder() ChapterSummary chapter = new ChapterSummary(
.name("L'arrivee") "L'arrivee",
.description("Les PJ decouvrent la ville") "Les PJ decouvrent la ville",
.scene(scene) 0,
.build(); List.of(scene));
ArcSummary arc = ArcSummary.builder() ArcSummary arc = new ArcSummary(
.name("Acte I") "Acte I",
.description("Mise en place") "Mise en place",
.illustrationCount(1) 1,
.chapter(chapter) List.of(chapter));
.build();
CampaignStructuralContext ctx = CampaignStructuralContext.builder() CampaignStructuralContext ctx = new CampaignStructuralContext(
.campaignName("Les Ombres") "Les Ombres",
.campaignDescription("Une campagne dark fantasy") "Une campagne dark fantasy",
.arc(arc) List.of(arc),
.build(); List.of());
assertEquals("Les Ombres", ctx.getCampaignName()); assertEquals("Les Ombres", ctx.campaignName());
assertEquals(1, ctx.getArcs().size()); assertEquals(1, ctx.arcs().size());
assertEquals(1, ctx.getArcs().get(0).getChapters().size()); assertEquals(1, ctx.arcs().get(0).chapters().size());
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().size()); assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().size());
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().size()); assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().get(0).branches().size());
} }
// --- BranchHint --------------------------------------------------------- // --- BranchHint ---------------------------------------------------------
@Test @Test
void branchHint_preservesAllFields() { void branchHint_preservesAllFields() {
BranchHint b = BranchHint.builder() BranchHint b = new BranchHint("combat", "La confrontation", "initiative > 15");
.label("combat")
.targetSceneName("La confrontation")
.condition("initiative > 15")
.build();
assertEquals("combat", b.getLabel()); assertEquals("combat", b.label());
assertEquals("La confrontation", b.getTargetSceneName()); assertEquals("La confrontation", b.targetSceneName());
assertEquals("initiative > 15", b.getCondition()); assertEquals("initiative > 15", b.condition());
} }
@Test @Test
void branchHint_conditionIsOptional() { void branchHint_conditionIsOptional() {
BranchHint b = BranchHint.builder() BranchHint b = new BranchHint("suite normale", "Scene 2", null);
.label("suite normale")
.targetSceneName("Scene 2")
.build();
assertNull(b.getCondition()); assertNull(b.condition());
} }
// --- illustrationCount -------------------------------------------------- // --- illustrationCount --------------------------------------------------
@Test @Test
void illustrationCount_defaultsToZero_onAllSummaryTypes() { void illustrationCount_defaultsToZero_onAllSummaryTypes() {
ArcSummary arc = ArcSummary.builder().name("X").build(); ArcSummary arc = new ArcSummary("X", null, 0, List.of());
ChapterSummary chapter = ChapterSummary.builder().name("X").build(); ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of());
SceneSummary scene = SceneSummary.builder().name("X").build(); SceneSummary scene = new SceneSummary("X", null, 0, List.of());
assertEquals(0, arc.getIllustrationCount()); assertEquals(0, arc.illustrationCount());
assertEquals(0, chapter.getIllustrationCount()); assertEquals(0, chapter.illustrationCount());
assertEquals(0, scene.getIllustrationCount()); assertEquals(0, scene.illustrationCount());
} }
// --- @Singular : accumulation incrementale ----------------------------- // --- Construction incrementale (chapitres multiples) -------------------
@Test @Test
void singular_accumulatesMultipleCalls() { void multipleChapters_arePreserved() {
ArcSummary arc = ArcSummary.builder() ArcSummary arc = new ArcSummary(
.name("Acte I") "Acte I",
.chapter(ChapterSummary.builder().name("Ch1").build()) null,
.chapter(ChapterSummary.builder().name("Ch2").build()) 0,
.chapter(ChapterSummary.builder().name("Ch3").build()) List.of(
.build(); 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()); assertEquals(3, arc.chapters().size());
assertTrue(arc.getChapters().stream().anyMatch(c -> "Ch2".equals(c.getName()))); assertEquals("Ch2", arc.chapters().get(1).name());
} }
} }

View File

@@ -3,6 +3,7 @@ package com.loremind.domain.generationcontext;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.List; import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -26,57 +27,45 @@ class ChatRequestTest {
void buildLoreOnly_leavesCampaignAndEntityNull() { void buildLoreOnly_leavesCampaignAndEntityNull() {
ChatRequest request = ChatRequest.builder() ChatRequest request = ChatRequest.builder()
.messages(sampleMessages) .messages(sampleMessages)
.loreContext(LoreStructuralContext.builder() .loreContext(new LoreStructuralContext("Ithoril", "Royaume sombre", Map.of(), List.of()))
.loreName("Ithoril")
.loreDescription("Royaume sombre")
.folders(java.util.Map.of())
.build())
.build(); .build();
assertEquals(1, request.getMessages().size()); assertEquals(1, request.messages().size());
assertNotNull(request.getLoreContext()); assertNotNull(request.loreContext());
assertEquals("Ithoril", request.getLoreContext().getLoreName()); assertEquals("Ithoril", request.loreContext().loreName());
assertNull(request.getPageContext()); assertNull(request.pageContext());
assertNull(request.getCampaignContext()); assertNull(request.campaignContext());
assertNull(request.getNarrativeEntity()); assertNull(request.narrativeEntity());
} }
@Test @Test
void buildLoreWithPageFocus_hasBothContexts() { void buildLoreWithPageFocus_hasBothContexts() {
ChatRequest request = ChatRequest.builder() ChatRequest request = ChatRequest.builder()
.messages(sampleMessages) .messages(sampleMessages)
.loreContext(LoreStructuralContext.builder().folders(java.util.Map.of()).build()) .loreContext(new LoreStructuralContext(null, null, Map.of(), List.of()))
.pageContext(PageContext.builder() .pageContext(new PageContext("Thorin", "PNJ", null, null))
.title("Thorin")
.templateName("PNJ")
.build())
.build(); .build();
assertNotNull(request.getLoreContext()); assertNotNull(request.loreContext());
assertNotNull(request.getPageContext()); assertNotNull(request.pageContext());
assertEquals("Thorin", request.getPageContext().getTitle()); assertEquals("Thorin", request.pageContext().title());
} }
@Test @Test
void buildCampaignWithNarrativeEntity_hasBothContexts() { void buildCampaignWithNarrativeEntity_hasBothContexts() {
ChatRequest request = ChatRequest.builder() ChatRequest request = ChatRequest.builder()
.messages(sampleMessages) .messages(sampleMessages)
.campaignContext(CampaignStructuralContext.builder() .campaignContext(new CampaignStructuralContext(
.campaignName("Les Ombres") "Les Ombres", "...", List.of(), List.of()))
.campaignDescription("...") .narrativeEntity(new NarrativeEntityContext(
.build()) "scene", "L'auberge", Map.of("location", "Taverne")))
.narrativeEntity(NarrativeEntityContext.builder()
.entityType("scene")
.title("L'auberge")
.fields(java.util.Map.of("location", "Taverne"))
.build())
.build(); .build();
assertNotNull(request.getCampaignContext()); assertNotNull(request.campaignContext());
assertNotNull(request.getNarrativeEntity()); assertNotNull(request.narrativeEntity());
assertEquals("scene", request.getNarrativeEntity().getEntityType()); assertEquals("scene", request.narrativeEntity().entityType());
assertNull(request.getLoreContext()); assertNull(request.loreContext());
assertNull(request.getPageContext()); assertNull(request.pageContext());
} }
@Test @Test
@@ -86,10 +75,10 @@ class ChatRequestTest {
.messages(sampleMessages) .messages(sampleMessages)
.build(); .build();
assertEquals(1, request.getMessages().size()); assertEquals(1, request.messages().size());
assertNull(request.getLoreContext()); assertNull(request.loreContext());
assertNull(request.getPageContext()); assertNull(request.pageContext());
assertNull(request.getCampaignContext()); assertNull(request.campaignContext());
assertNull(request.getNarrativeEntity()); assertNull(request.narrativeEntity());
} }
} }

View File

@@ -9,41 +9,38 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals;
/** /**
* Tests unitaires pour GenerationContext (Value Object pour la generation one-shot). * 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 { class GenerationContextTest {
@Test @Test
void builder_preservesAllFields() { void constructor_preservesAllFields() {
GenerationContext ctx = GenerationContext.builder() GenerationContext ctx = new GenerationContext(
.loreName("Ithoril") "Ithoril",
.loreDescription("Royaume sombre") "Royaume sombre",
.folderName("PNJ") "PNJ",
.templateName("Fiche PNJ") "Fiche PNJ",
.templateFields(List.of("histoire", "motto", "apparence")) List.of("histoire", "motto", "apparence"),
.pageTitle("Thorin") "Thorin");
.build();
assertEquals("Ithoril", ctx.getLoreName()); assertEquals("Ithoril", ctx.loreName());
assertEquals("PNJ", ctx.getFolderName()); assertEquals("PNJ", ctx.folderName());
assertEquals("Fiche PNJ", ctx.getTemplateName()); assertEquals("Fiche PNJ", ctx.templateName());
assertEquals(3, ctx.getTemplateFields().size()); assertEquals(3, ctx.templateFields().size());
assertEquals("Thorin", ctx.getPageTitle()); assertEquals("Thorin", ctx.pageTitle());
} }
@Test @Test
void twoContexts_withSameFields_areEqual() { void twoContexts_withSameFields_areEqual() {
GenerationContext a = GenerationContext.builder() GenerationContext a = new GenerationContext("X", null, null, null, List.of("f1"), "A");
.loreName("X").pageTitle("A").templateFields(List.of("f1")).build(); GenerationContext b = new GenerationContext("X", null, null, null, List.of("f1"), "A");
GenerationContext b = GenerationContext.builder()
.loreName("X").pageTitle("A").templateFields(List.of("f1")).build();
assertEquals(a, b); assertEquals(a, b);
} }
@Test @Test
void twoContexts_differingOnPageTitle_areNotEqual() { void twoContexts_differingOnPageTitle_areNotEqual() {
GenerationContext a = GenerationContext.builder().pageTitle("A").build(); GenerationContext a = new GenerationContext(null, null, null, null, null, "A");
GenerationContext b = GenerationContext.builder().pageTitle("B").build(); GenerationContext b = new GenerationContext(null, null, null, null, null, "B");
assertNotEquals(a, b); assertNotEquals(a, b);
} }
} }

View File

@@ -12,66 +12,61 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
/** /**
* Tests unitaires pour LoreStructuralContext et son type imbrique PageSummary. * Tests unitaires pour LoreStructuralContext et son type imbrique PageSummary.
* Valide le @Singular de Lombok sur {@code tags} (alimentation incrementale via * Records purs : aucune dependance technique.
* {@code tag(...)} vs initialisation groupee via {@code tags(...)}).
*/ */
class LoreStructuralContextTest { class LoreStructuralContextTest {
@Test @Test
void builder_preservesFoldersAndTags() { void constructor_preservesFoldersAndTags() {
PageSummary pnj = PageSummary.builder() PageSummary pnj = new PageSummary(
.title("Thorin") "Thorin",
.templateName("PNJ") "PNJ",
.values(Map.of("histoire", "Nee sous une etoile rouge")) Map.of("histoire", "Nee sous une etoile rouge"),
.tags(List.of("pnj", "allie")) List.of("pnj", "allie"),
.relatedPageTitles(List.of("Taverne du Dragon d'Or")) List.of("Taverne du Dragon d'Or"));
.build();
LoreStructuralContext ctx = LoreStructuralContext.builder() LoreStructuralContext ctx = new LoreStructuralContext(
.loreName("Ithoril") "Ithoril",
.loreDescription("Royaume sombre") "Royaume sombre",
.folders(Map.of("PNJ", List.of(pnj))) Map.of("PNJ", List.of(pnj)),
.tag("royaume") List.of("royaume", "dark-fantasy"));
.tag("dark-fantasy")
.build();
assertEquals("Ithoril", ctx.getLoreName()); assertEquals("Ithoril", ctx.loreName());
assertEquals(1, ctx.getFolders().size()); assertEquals(1, ctx.folders().size());
assertEquals(1, ctx.getFolders().get("PNJ").size()); assertEquals(1, ctx.folders().get("PNJ").size());
assertEquals(2, ctx.getTags().size(), "@Singular doit accumuler les appels tag()"); assertEquals(2, ctx.tags().size());
assertTrue(ctx.getTags().contains("royaume")); assertTrue(ctx.tags().contains("royaume"));
assertTrue(ctx.getTags().contains("dark-fantasy")); assertTrue(ctx.tags().contains("dark-fantasy"));
} }
@Test @Test
void emptyFolders_areAllowed() { void emptyFolders_areAllowed() {
// Dossier vide : legitime (ex: dossier "Lieux" cree mais pas encore peuple). // Dossier vide : legitime (ex: dossier "Lieux" cree mais pas encore peuple).
LoreStructuralContext ctx = LoreStructuralContext.builder() LoreStructuralContext ctx = new LoreStructuralContext(
.loreName("Vide") "Vide",
.loreDescription("") "",
.folders(Map.of("Lieux", List.of())) Map.of("Lieux", List.of()),
.build(); List.of());
assertNotNull(ctx.getFolders().get("Lieux")); assertNotNull(ctx.folders().get("Lieux"));
assertTrue(ctx.getFolders().get("Lieux").isEmpty()); assertTrue(ctx.folders().get("Lieux").isEmpty());
} }
// --- PageSummary -------------------------------------------------------- // --- PageSummary --------------------------------------------------------
@Test @Test
void pageSummary_preservesAllFields() { void pageSummary_preservesAllFields() {
PageSummary ps = PageSummary.builder() PageSummary ps = new PageSummary(
.title("Le Donjon du Chaos") "Le Donjon du Chaos",
.templateName("Lieu") "Lieu",
.values(Map.of("histoire", "Bati il y a 1000 ans...")) Map.of("histoire", "Bati il y a 1000 ans..."),
.tags(List.of("donjon", "ancien")) List.of("donjon", "ancien"),
.relatedPageTitles(List.of("Thorin", "Garde royale")) List.of("Thorin", "Garde royale"));
.build();
assertEquals("Le Donjon du Chaos", ps.getTitle()); assertEquals("Le Donjon du Chaos", ps.title());
assertEquals("Lieu", ps.getTemplateName()); assertEquals("Lieu", ps.templateName());
assertEquals(1, ps.getValues().size()); assertEquals(1, ps.values().size());
assertEquals(2, ps.getTags().size()); assertEquals(2, ps.tags().size());
assertEquals(2, ps.getRelatedPageTitles().size()); assertEquals(2, ps.relatedPageTitles().size());
} }
} }

View File

@@ -16,21 +16,17 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals;
class NarrativeEntityContextTest { class NarrativeEntityContextTest {
@Test @Test
void builder_preservesAllFields() { void constructor_preservesAllFields() {
Map<String, String> fields = new LinkedHashMap<>(); Map<String, String> fields = new LinkedHashMap<>();
fields.put("themes", "trahison"); fields.put("themes", "trahison");
fields.put("stakes", "la survie du royaume"); fields.put("stakes", "la survie du royaume");
NarrativeEntityContext ctx = NarrativeEntityContext.builder() NarrativeEntityContext ctx = new NarrativeEntityContext("arc", "Acte I", fields);
.entityType("arc")
.title("Acte I")
.fields(fields)
.build();
assertEquals("arc", ctx.getEntityType()); assertEquals("arc", ctx.entityType());
assertEquals("Acte I", ctx.getTitle()); assertEquals("Acte I", ctx.title());
assertEquals(2, ctx.getFields().size()); assertEquals(2, ctx.fields().size());
assertEquals("trahison", ctx.getFields().get("themes")); assertEquals("trahison", ctx.fields().get("themes"));
} }
@Test @Test
@@ -41,19 +37,15 @@ class NarrativeEntityContextTest {
fields.put("timing", "Soir"); fields.put("timing", "Soir");
fields.put("atmosphere", "fumee"); fields.put("atmosphere", "fumee");
NarrativeEntityContext ctx = NarrativeEntityContext.builder() NarrativeEntityContext ctx = new NarrativeEntityContext("scene", "L'auberge", fields);
.entityType("scene")
.title("L'auberge")
.fields(fields)
.build();
assertEquals("[location, timing, atmosphere]", ctx.getFields().keySet().toString()); assertEquals("[location, timing, atmosphere]", ctx.fields().keySet().toString());
} }
@Test @Test
void twoContexts_differingOnEntityType_areNotEqual() { void twoContexts_differingOnEntityType_areNotEqual() {
NarrativeEntityContext a = NarrativeEntityContext.builder().entityType("arc").title("X").build(); NarrativeEntityContext a = new NarrativeEntityContext("arc", "X", Map.of());
NarrativeEntityContext b = NarrativeEntityContext.builder().entityType("scene").title("X").build(); NarrativeEntityContext b = new NarrativeEntityContext("scene", "X", Map.of());
assertNotEquals(a, b); assertNotEquals(a, b);
} }
} }

View File

@@ -14,31 +14,29 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class PageContextTest { class PageContextTest {
@Test @Test
void builder_preservesAllFields() { void constructor_preservesAllFields() {
PageContext ctx = PageContext.builder() PageContext ctx = new PageContext(
.title("Thorin") "Thorin",
.templateName("PNJ") "PNJ",
.templateFields(List.of("histoire", "apparence", "motto")) List.of("histoire", "apparence", "motto"),
.values(Map.of("histoire", "Nee sous une etoile rouge")) Map.of("histoire", "Nee sous une etoile rouge"));
.build();
assertEquals("Thorin", ctx.getTitle()); assertEquals("Thorin", ctx.title());
assertEquals("PNJ", ctx.getTemplateName()); assertEquals("PNJ", ctx.templateName());
assertEquals(3, ctx.getTemplateFields().size()); assertEquals(3, ctx.templateFields().size());
assertEquals(1, ctx.getValues().size()); assertEquals(1, ctx.values().size());
} }
@Test @Test
void emptyValues_areAllowed() { void emptyValues_areAllowed() {
// Page vierge : template defini mais aucun champ rempli (cas generation ex-nihilo). // Page vierge : template defini mais aucun champ rempli (cas generation ex-nihilo).
PageContext ctx = PageContext.builder() PageContext ctx = new PageContext(
.title("Nouveau PNJ") "Nouveau PNJ",
.templateName("PNJ") "PNJ",
.templateFields(List.of("histoire", "apparence")) List.of("histoire", "apparence"),
.values(Map.of()) Map.of());
.build();
assertTrue(ctx.getValues().isEmpty()); assertTrue(ctx.values().isEmpty());
assertEquals(2, ctx.getTemplateFields().size()); assertEquals(2, ctx.templateFields().size());
} }
} }

View File

@@ -83,12 +83,8 @@ class BrainChatPayloadBuilderTest {
@Test @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void build_loreContext_includesBasicFields() { void build_loreContext_includesBasicFields() {
LoreStructuralContext lore = LoreStructuralContext.builder() LoreStructuralContext lore = new LoreStructuralContext(
.loreName("Ithoril") "Ithoril", "Royaume sombre", Map.of(), List.of("dark-fantasy"));
.loreDescription("Royaume sombre")
.folders(Map.of())
.tag("dark-fantasy")
.build();
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
Map<String, Object> payload = builder.build(req); Map<String, Object> payload = builder.build(req);
@@ -103,17 +99,10 @@ class BrainChatPayloadBuilderTest {
@Test @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void build_pageSummary_omitsEmptyValuesTagsAndRelated() { void build_pageSummary_omitsEmptyValuesTagsAndRelated() {
PageSummary minimal = PageSummary.builder() PageSummary minimal = new PageSummary("Thorin", "PNJ",
.title("Thorin") Map.of(), List.of(), List.of());
.templateName("PNJ") LoreStructuralContext lore = new LoreStructuralContext(
.values(Map.of()) "X", "", Map.of("PNJ", List.of(minimal)), List.of());
.tags(List.of())
.relatedPageTitles(List.of())
.build();
LoreStructuralContext lore = LoreStructuralContext.builder()
.loreName("X").loreDescription("")
.folders(Map.of("PNJ", List.of(minimal)))
.build();
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
Map<String, Object> payload = builder.build(req); Map<String, Object> payload = builder.build(req);
@@ -132,17 +121,12 @@ class BrainChatPayloadBuilderTest {
@Test @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void build_pageSummary_includesNonEmptyValuesTagsAndRelated() { void build_pageSummary_includesNonEmptyValuesTagsAndRelated() {
PageSummary full = PageSummary.builder() PageSummary full = new PageSummary("Thorin", "PNJ",
.title("Thorin") Map.of("histoire", "Nee sous une etoile rouge"),
.templateName("PNJ") List.of("pnj", "allie"),
.values(Map.of("histoire", "Nee sous une etoile rouge")) List.of("Taverne du Dragon d'Or"));
.tags(List.of("pnj", "allie")) LoreStructuralContext lore = new LoreStructuralContext(
.relatedPageTitles(List.of("Taverne du Dragon d'Or")) "X", "", Map.of("PNJ", List.of(full)), List.of());
.build();
LoreStructuralContext lore = LoreStructuralContext.builder()
.loreName("X").loreDescription("")
.folders(Map.of("PNJ", List.of(full)))
.build();
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
Map<String, Object> payload = builder.build(req); Map<String, Object> payload = builder.build(req);
@@ -161,12 +145,8 @@ class BrainChatPayloadBuilderTest {
@Test @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void build_pageContext_includesAllFields() { void build_pageContext_includesAllFields() {
PageContext pc = PageContext.builder() PageContext pc = new PageContext("Thorin", "PNJ",
.title("Thorin") List.of("histoire", "motto"), Map.of("histoire", "..."));
.templateName("PNJ")
.templateFields(List.of("histoire", "motto"))
.values(Map.of("histoire", "..."))
.build();
ChatRequest req = ChatRequest.builder().messages(sampleMessages).pageContext(pc).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).pageContext(pc).build();
Map<String, Object> payload = builder.build(req); Map<String, Object> payload = builder.build(req);
@@ -182,17 +162,12 @@ class BrainChatPayloadBuilderTest {
@Test @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void build_campaignContext_serializesFullNarrativeTree() { void build_campaignContext_serializesFullNarrativeTree() {
BranchHint branch = BranchHint.builder() BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%");
.label("fuite").targetSceneName("La poursuite").condition("HP < 50%").build(); SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch));
SceneSummary scene = SceneSummary.builder() ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
.name("L'auberge").description("Rencontre tendue") ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter));
.illustrationCount(3).branch(branch).build(); CampaignStructuralContext camp = new CampaignStructuralContext(
ChapterSummary chapter = ChapterSummary.builder() "Les Ombres", "dark fantasy", List.of(arc), List.of());
.name("L'arrivee").description("...").scene(scene).build();
ArcSummary arc = ArcSummary.builder()
.name("Acte I").description("Mise en place").illustrationCount(1).chapter(chapter).build();
CampaignStructuralContext camp = CampaignStructuralContext.builder()
.campaignName("Les Ombres").campaignDescription("dark fantasy").arc(arc).build();
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req); Map<String, Object> payload = builder.build(req);
@@ -223,9 +198,9 @@ class BrainChatPayloadBuilderTest {
@Test @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void build_arcSummary_omitsIllustrationCount_whenZero() { void build_arcSummary_omitsIllustrationCount_whenZero() {
ArcSummary arc = ArcSummary.builder().name("A").description("").illustrationCount(0).build(); ArcSummary arc = new ArcSummary("A", "", 0, List.of());
CampaignStructuralContext camp = CampaignStructuralContext.builder() CampaignStructuralContext camp = new CampaignStructuralContext(
.campaignName("X").campaignDescription("").arc(arc).build(); "X", "", List.of(arc), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req); Map<String, Object> payload = builder.build(req);
@@ -238,11 +213,11 @@ class BrainChatPayloadBuilderTest {
@Test @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void build_sceneSummary_omitsBranches_whenEmpty() { void build_sceneSummary_omitsBranches_whenEmpty() {
SceneSummary scene = SceneSummary.builder().name("S").description("").build(); SceneSummary scene = new SceneSummary("S", "", 0, List.of());
ChapterSummary chapter = ChapterSummary.builder().name("Ch").description("").scene(scene).build(); ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = ArcSummary.builder().name("A").description("").chapter(chapter).build(); ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
CampaignStructuralContext camp = CampaignStructuralContext.builder() CampaignStructuralContext camp = new CampaignStructuralContext(
.campaignName("X").campaignDescription("").arc(arc).build(); "X", "", List.of(arc), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req); Map<String, Object> payload = builder.build(req);
@@ -256,12 +231,12 @@ class BrainChatPayloadBuilderTest {
@Test @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void build_branchHint_omitsCondition_whenBlank() { void build_branchHint_omitsCondition_whenBlank() {
BranchHint branch = BranchHint.builder().label("X").targetSceneName("Y").condition(" ").build(); BranchHint branch = new BranchHint("X", "Y", " ");
SceneSummary scene = SceneSummary.builder().name("S").description("").branch(branch).build(); SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch));
ChapterSummary chapter = ChapterSummary.builder().name("Ch").description("").scene(scene).build(); ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = ArcSummary.builder().name("A").description("").chapter(chapter).build(); ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
CampaignStructuralContext camp = CampaignStructuralContext.builder() CampaignStructuralContext camp = new CampaignStructuralContext(
.campaignName("X").campaignDescription("").arc(arc).build(); "X", "", List.of(arc), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req); Map<String, Object> payload = builder.build(req);
@@ -278,10 +253,8 @@ class BrainChatPayloadBuilderTest {
@Test @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void build_narrativeEntity_includesAllFields() { void build_narrativeEntity_includesAllFields() {
NarrativeEntityContext entity = NarrativeEntityContext.builder() NarrativeEntityContext entity = new NarrativeEntityContext("scene", "L'auberge",
.entityType("scene").title("L'auberge") Map.of("location", "Taverne", "timing", "Soir"));
.fields(Map.of("location", "Taverne", "timing", "Soir"))
.build();
ChatRequest req = ChatRequest.builder().messages(sampleMessages).narrativeEntity(entity).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).narrativeEntity(entity).build();
Map<String, Object> payload = builder.build(req); Map<String, Object> payload = builder.build(req);
@@ -295,10 +268,9 @@ class BrainChatPayloadBuilderTest {
@Test @Test
void build_campaignScenario_includesBothContextsAndEntity() { void build_campaignScenario_includesBothContextsAndEntity() {
CampaignStructuralContext camp = CampaignStructuralContext.builder() CampaignStructuralContext camp = new CampaignStructuralContext(
.campaignName("X").campaignDescription("").build(); "X", "", List.of(), List.of());
NarrativeEntityContext entity = NarrativeEntityContext.builder() NarrativeEntityContext entity = new NarrativeEntityContext("arc", "T", Map.of());
.entityType("arc").title("T").fields(Map.of()).build();
ChatRequest req = ChatRequest.builder() ChatRequest req = ChatRequest.builder()
.messages(sampleMessages) .messages(sampleMessages)
.campaignContext(camp) .campaignContext(camp)

View File

@@ -48,27 +48,21 @@ class SceneBranchListJsonConverterTest {
@Test @Test
void roundTrip_preservesAllBranchFields() { 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( List<SceneBranch> source = List.of(
SceneBranch.builder() new SceneBranch("si les joueurs attaquent", "sc-combat", "initiative > 15"),
.label("si les joueurs attaquent") SceneBranch.of("si les joueurs fuient", "sc-poursuite")
.targetSceneId("sc-combat")
.condition("initiative > 15")
.build(),
SceneBranch.builder()
.label("si les joueurs fuient")
.targetSceneId("sc-poursuite")
.build()
); );
String json = converter.convertToDatabaseColumn(source); String json = converter.convertToDatabaseColumn(source);
List<SceneBranch> back = converter.convertToEntityAttribute(json); List<SceneBranch> back = converter.convertToEntityAttribute(json);
assertEquals(2, back.size()); assertEquals(2, back.size());
assertEquals("si les joueurs attaquent", back.get(0).getLabel()); assertEquals("si les joueurs attaquent", back.get(0).label());
assertEquals("sc-combat", back.get(0).getTargetSceneId()); assertEquals("sc-combat", back.get(0).targetSceneId());
assertEquals("initiative > 15", back.get(0).getCondition()); assertEquals("initiative > 15", back.get(0).condition());
assertEquals("sc-poursuite", back.get(1).getTargetSceneId()); assertEquals("sc-poursuite", back.get(1).targetSceneId());
assertNull(back.get(1).getCondition(), "condition absente doit rester null apres round-trip"); assertNull(back.get(1).condition(), "condition absente doit rester null apres round-trip");
} }
} }

View File

@@ -70,13 +70,13 @@ class PostgresSceneRepositoryTest {
@Test @Test
void save_scenePreservesBranches_viaJsonbRoundTrip() { void save_scenePreservesBranches_viaJsonbRoundTrip() {
// Le critique : le @Jacksonized de SceneBranch doit permettre la // Le critique : SceneBranch (record) doit etre reconstructible par
// reconstruction via builder apres serialisation Jackson. // Jackson via le constructeur canonique apres serialisation JSON.
Scene scene = Scene.builder() Scene scene = Scene.builder()
.chapterId(chapterId).name("Decision").order(0) .chapterId(chapterId).name("Decision").order(0)
.branches(List.of( .branches(List.of(
SceneBranch.builder().label("fuite").targetSceneId("sc-2").condition("HP bas").build(), new SceneBranch("fuite", "sc-2", "HP bas"),
SceneBranch.builder().label("combat").targetSceneId("sc-3").build() SceneBranch.of("combat", "sc-3")
)) ))
.build(); .build();
@@ -84,10 +84,10 @@ class PostgresSceneRepositoryTest {
Scene r = repository.findById(saved.getId()).orElseThrow(); Scene r = repository.findById(saved.getId()).orElseThrow();
assertEquals(2, r.getBranches().size()); assertEquals(2, r.getBranches().size());
assertEquals("fuite", r.getBranches().get(0).getLabel()); assertEquals("fuite", r.getBranches().get(0).label());
assertEquals("sc-2", r.getBranches().get(0).getTargetSceneId()); assertEquals("sc-2", r.getBranches().get(0).targetSceneId());
assertEquals("HP bas", r.getBranches().get(0).getCondition()); assertEquals("HP bas", r.getBranches().get(0).condition());
assertEquals("combat", r.getBranches().get(1).getLabel()); assertEquals("combat", r.getBranches().get(1).label());
} }
@Test @Test

View File

@@ -79,7 +79,7 @@ class ArcControllerTest {
@Test @Test
void getByCampaign_pathVariant() throws Exception { void getByCampaign_pathVariant() throws Exception {
arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build()); arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build());
mockMvc.perform(get("/api/arcs/campaign/{id}", campaignId)) mockMvc.perform(get("/api/arcs").param("campaignId", campaignId))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$").isArray()); .andExpect(jsonPath("$").isArray());
} }

View File

@@ -81,7 +81,7 @@ class ChapterControllerTest {
@Test @Test
void getByArc_pathVariant() throws Exception { void getByArc_pathVariant() throws Exception {
chapterRepository.save(Chapter.builder().arcId(arcId).name("A").order(0).build()); chapterRepository.save(Chapter.builder().arcId(arcId).name("A").order(0).build());
mockMvc.perform(get("/api/chapters/arc/{id}", arcId)) mockMvc.perform(get("/api/chapters").param("arcId", arcId))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$").isArray()); .andExpect(jsonPath("$").isArray());
} }

View File

@@ -85,7 +85,7 @@ class SceneControllerTest {
@Test @Test
void getByChapter_pathVariant() throws Exception { void getByChapter_pathVariant() throws Exception {
sceneRepository.save(Scene.builder().chapterId(chapterId).name("A").order(0).build()); sceneRepository.save(Scene.builder().chapterId(chapterId).name("A").order(0).build());
mockMvc.perform(get("/api/scenes/chapter/{id}", chapterId)) mockMvc.perform(get("/api/scenes").param("chapterId", chapterId))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$").isArray()); .andExpect(jsonPath("$").isArray());
} }

View File

@@ -51,6 +51,8 @@ services:
BRAIN_INTERNAL_SECRET_DEFAULT: ${BRAIN_INTERNAL_SECRET_DEFAULT:-change-me} BRAIN_INTERNAL_SECRET_DEFAULT: ${BRAIN_INTERNAL_SECRET_DEFAULT:-change-me}
# Rate limit : 1 creation par IP par fenetre (en secondes). # Rate limit : 1 creation par IP par fenetre (en secondes).
RATE_LIMIT_WINDOW_SECONDS: ${RATE_LIMIT_WINDOW_SECONDS:-60} RATE_LIMIT_WINDOW_SECONDS: ${RATE_LIMIT_WINDOW_SECONDS:-60}
# Domaine public : propage aux cores de session pour configurer CORS.
DEMO_HOST: ${DEMO_HOST:-loremind-demo.igmlcreation.fr}
networks: networks:
- traefik - traefik
- sessions - sessions

View File

@@ -22,6 +22,7 @@ type Config struct {
PreparingPage string PreparingPage string
RateLimitWindow time.Duration RateLimitWindow time.Duration
MaxBodyBytes int64 MaxBodyBytes int64
DemoHost string
} }
func loadConfig() *Config { func loadConfig() *Config {
@@ -40,6 +41,9 @@ func loadConfig() *Config {
RateLimitWindow: time.Duration(envInt("RATE_LIMIT_WINDOW_SECONDS", 60)) * time.Second, RateLimitWindow: time.Duration(envInt("RATE_LIMIT_WINDOW_SECONDS", 60)) * time.Second,
// 10 Mo : aligne avec la limite d'upload d'image cote core. // 10 Mo : aligne avec la limite d'upload d'image cote core.
MaxBodyBytes: int64(envInt("MAX_BODY_MB", 10)) * 1024 * 1024, MaxBodyBytes: int64(envInt("MAX_BODY_MB", 10)) * 1024 * 1024,
// Utilise pour injecter APP_CORS_ALLOWED_ORIGINS dans les cores spawnes :
// sans ca, Spring bloque les POST avec 403 (origine rejetee).
DemoHost: envStr("DEMO_HOST", "loremind-demo.igmlcreation.fr"),
} }
} }

View File

@@ -139,7 +139,11 @@ func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Con
"ADMIN_USERNAME=admin", "ADMIN_USERNAME=admin",
"ADMIN_PASSWORD=" + adminPassword, "ADMIN_PASSWORD=" + adminPassword,
"DEMO_MODE=true", "DEMO_MODE=true",
"CORS_ALLOWED_ORIGINS=*", // CorsConfig.java lit app.cors.allowed-origins (= APP_CORS_ALLOWED_ORIGINS
// via le relaxed binding Spring). Necessaire meme en same-origin car
// le browser envoie Origin sur les POST et le CorsFilter 403 les
// origines inconnues.
"APP_CORS_ALLOWED_ORIGINS=https://" + cfg.DemoHost,
}, },
Labels: copyLabels(labels, "core"), Labels: copyLabels(labels, "core"),
Memory: cfg.CoreMemoryBytes, Memory: cfg.CoreMemoryBytes,

View File

@@ -56,11 +56,18 @@ func (rl *rateLimiter) cleanupLoop() {
} }
} }
// clientIP extrait l'IP reelle en prenant la derniere entree de X-Forwarded-For. // clientIP extrait l'IP reelle du visiteur en tenant compte du setup reverse-proxy.
// Justification : Traefik APPEND l'IP du peer au header existant, donc la // Ordre de priorite :
// derniere valeur est celle que Traefik a observe directement (le vrai client). // 1. CF-Connecting-IP : defini par Cloudflare sur la base de SA propre vue du
// Prendre la premiere serait une faille : un attaquant peut preremplir le header. // peer TCP, non-forgeable par le client, ecrase toute valeur entrante.
// 2. X-Forwarded-For, derniere entree : quand seul Traefik est en front (pas
// de Cloudflare), Traefik append l'IP qu'il observe. Prendre la premiere
// serait une faille (header forgeable).
// 3. RemoteAddr : fallback si aucun header de proxy n'est present.
func clientIP(r *http.Request) string { func clientIP(r *http.Request) string {
if cfIP := r.Header.Get("CF-Connecting-IP"); cfIP != "" {
return strings.TrimSpace(cfIP)
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",") parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[len(parts)-1]) return strings.TrimSpace(parts[len(parts)-1])

17
docker-compose.e2e.yml Normal file
View File

@@ -0,0 +1,17 @@
# Override pour la CI E2E : build les images depuis les sources au lieu de les puller.
# Usage : docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build
services:
core:
build:
context: ./core
image: loremind-core:e2e
brain:
build:
context: ./brain
image: loremind-brain:e2e
web:
build:
context: ./web
image: loremind-web:e2e

View File

@@ -62,6 +62,8 @@ services:
core: core:
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/core:${TAG:-latest} image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/core:${TAG:-latest}
container_name: loremind-core container_name: loremind-core
labels:
- "com.centurylinklabs.watchtower.enable=true"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -79,11 +81,21 @@ services:
MINIO_ENDPOINT: http://minio:9000 MINIO_ENDPOINT: http://minio:9000
MINIO_ACCESS_KEY: ${MINIO_USER:-minioadmin} MINIO_ACCESS_KEY: ${MINIO_USER:-minioadmin}
MINIO_SECRET_KEY: ${MINIO_PASSWORD:-minioadmin} MINIO_SECRET_KEY: ${MINIO_PASSWORD:-minioadmin}
# Detection des mises a jour : interroge le registry et delegue le pull/restart
# a Watchtower. Si WATCHTOWER_TOKEN est vide, la feature est desactivee
# (l'UI masque le badge et le bouton).
UPDATE_CHECK_REGISTRY: ${REGISTRY:-git.igmlcreation.fr}
UPDATE_CHECK_IMAGES: ietm64/core,ietm64/brain,ietm64/web
UPDATE_CHECK_TAG: ${TAG:-latest}
WATCHTOWER_URL: http://watchtower:8080
WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:-}
restart: unless-stopped restart: unless-stopped
brain: brain:
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/brain:${TAG:-latest} image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/brain:${TAG:-latest}
container_name: loremind-brain container_name: loremind-brain
labels:
- "com.centurylinklabs.watchtower.enable=true"
environment: environment:
LLM_PROVIDER: ${LLM_PROVIDER:-ollama} LLM_PROVIDER: ${LLM_PROVIDER:-ollama}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434} OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
@@ -102,6 +114,8 @@ services:
web: web:
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/web:${TAG:-latest} image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/web:${TAG:-latest}
container_name: loremind-web container_name: loremind-web
labels:
- "com.centurylinklabs.watchtower.enable=true"
depends_on: depends_on:
- core - core
- brain - brain
@@ -109,6 +123,33 @@ services:
- "${WEB_PORT:-8081}:80" - "${WEB_PORT:-8081}:80"
restart: unless-stopped restart: unless-stopped
# Mises a jour automatiques des images core/brain/web.
# Active uniquement si COMPOSE_PROFILES=autoupdate (gere par l'installeur).
# Postgres et MinIO sont volontairement exclus (donnees persistantes,
# compatibilite de version a verifier manuellement).
watchtower:
image: containrrr/watchtower:latest
container_name: loremind-watchtower
profiles: ["autoupdate"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
WATCHTOWER_LABEL_ENABLE: "true"
WATCHTOWER_CLEANUP: "true"
WATCHTOWER_INCLUDE_RESTARTING: "true"
# MONITOR_ONLY=true => detecte sans appliquer (l'UI declenche manuellement).
# MONITOR_ONLY=false => applique automatiquement selon WATCHTOWER_SCHEDULE.
WATCHTOWER_MONITOR_ONLY: "${WATCHTOWER_MONITOR_ONLY:-false}"
WATCHTOWER_SCHEDULE: "${WATCHTOWER_SCHEDULE:-0 0 4 * * *}"
# API HTTP pour declenchement manuel via le bouton UI (Core -> Watchtower).
WATCHTOWER_HTTP_API_UPDATE: "true"
WATCHTOWER_HTTP_API_PERIODIC_POLLS: "true"
WATCHTOWER_HTTP_API_TOKEN: "${WATCHTOWER_TOKEN:?set WATCHTOWER_TOKEN in .env (re-run installer)}"
WATCHTOWER_TIMEOUT: 60s
WATCHTOWER_NOTIFICATIONS_LEVEL: info
TZ: ${TZ:-Europe/Paris}
restart: unless-stopped
volumes: volumes:
postgres-data: postgres-data:
minio-data: minio-data:

109
installers/README.md Normal file
View File

@@ -0,0 +1,109 @@
# LoreMindMJ — Installation rapide
Ces scripts installent Docker (si nécessaire), génèrent un `.env` sécurisé
et lancent la stack. Aucune configuration manuelle requise.
## Windows 10 / 11
Ouvrir **PowerShell** (clic droit → *Exécuter en tant qu'administrateur*) :
```powershell
iwr https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/installers/install.ps1 -OutFile $env:TEMP\loremind-install.ps1
powershell -ExecutionPolicy Bypass -File $env:TEMP\loremind-install.ps1
```
Le script :
1. Vérifie / installe **WSL2** (un reboot peut être nécessaire — relancer le script après).
2. Vérifie / installe **Docker Desktop** via `winget`.
3. Génère `%LOCALAPPDATA%\LoreMind\.env` avec mots de passe aléatoires.
4. Lance la stack et ouvre `http://localhost:8081`.
## Linux (Debian / Ubuntu / Fedora / Arch)
```bash
curl -fsSL https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/installers/install.sh | bash
```
Le script :
1. Installe **Docker** via le script officiel `get.docker.com` si absent.
2. Ajoute l'utilisateur courant au groupe `docker` (relogin nécessaire la 1ʳᵉ fois).
3. Installe dans `~/.local/share/loremind`.
4. Lance la stack et ouvre `http://localhost:8081`.
## Variables disponibles
| Variable | Défaut | Effet |
|-------------------|---------------------------------|----------------------------------------|
| `WEB_PORT` | `8081` | Port HTTP de l'UI |
| `INSTALL_DIR` | `~/.local/share/loremind` (Lin) | Dossier d'installation |
| `NON_INTERACTIVE` | `0` | `1` = aucune question, valeurs par défaut |
Exemple Linux non-interactif sur port 9000 :
```bash
WEB_PORT=9000 NON_INTERACTIVE=1 bash install.sh
```
## Mises à jour automatiques (Watchtower)
Si vous avez répondu **oui** à la question "Activer les mises à jour auto",
un container [Watchtower](https://containrrr.dev/watchtower/) est lancé en
parallèle. Il vérifie chaque nuit à 4h les nouvelles versions de
`core`, `brain` et `web` sur le registry, télécharge et redémarre les
conteneurs concernés. **Postgres et MinIO sont volontairement exclus**
(données persistantes — montée de version à valider manuellement).
### Activer / désactiver après coup
Éditer `.env` dans le dossier d'installation :
```env
COMPOSE_PROFILES=autoupdate # active
COMPOSE_PROFILES= # desactive
```
Puis :
```bash
docker compose up -d # applique le changement
docker compose stop watchtower # si on vient de le desactiver
```
### Changer l'horaire
`WATCHTOWER_SCHEDULE` dans `.env` accepte la syntaxe
[cron 6 champs](https://pkg.go.dev/github.com/robfig/cron) (sec min h jour mois j-sem).
Exemples : `0 0 4 * * *` (4h du matin, défaut), `0 30 3 * * 0` (dimanche 3h30).
### Mode "notification seulement" (sans auto-apply)
Si vous préférez être notifié *sans* que les conteneurs redémarrent
automatiquement la nuit, éditez `.env` :
```env
WATCHTOWER_MONITOR_ONLY=true
```
Puis `docker compose up -d watchtower`. Watchtower continuera à vérifier
le registry chaque nuit, le badge **MAJ** apparaîtra dans la sidebar de
l'UI, et un bouton **Mettre à jour maintenant** sera disponible dans
*Paramètres → Mises à jour*.
### Mise à jour manuelle (à tout moment)
Depuis l'interface : *Paramètres → Mises à jour → Mettre à jour maintenant*.
Ou en CLI :
```bash
docker compose pull && docker compose up -d
```
## Désinstallation
```bash
cd <dossier d'install>
docker compose down -v # -v supprime aussi les volumes (données effacées !)
```
Puis supprimer le dossier d'installation.

240
installers/install.ps1 Normal file
View File

@@ -0,0 +1,240 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Installeur LoreMindMJ pour Windows 10/11.
.DESCRIPTION
- Verifie / installe WSL2 et Docker Desktop (via winget)
- Genere un .env avec mots de passe aleatoires
- Recupere le docker-compose.yml officiel
- Lance la stack et ouvre le navigateur
.EXAMPLE
iwr https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/installers/install.ps1 | iex
#>
[CmdletBinding()]
param(
[string]$InstallDir = "$env:LOCALAPPDATA\LoreMind",
[string]$ComposeUrl = "https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/docker-compose.yml",
[int]$WebPort = 8081,
[switch]$NonInteractive
)
$ErrorActionPreference = 'Stop'
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
function Write-Ok($msg) { Write-Host " OK $msg" -ForegroundColor Green }
function Write-Warn2($msg) { Write-Host " !! $msg" -ForegroundColor Yellow }
function Write-Err($msg) { Write-Host " XX $msg" -ForegroundColor Red }
function Test-Admin {
$current = [Security.Principal.WindowsIdentity]::GetCurrent()
return ([Security.Principal.WindowsPrincipal]$current).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Invoke-Elevated {
Write-Step "Relance en mode administrateur..."
$args = @('-NoProfile','-ExecutionPolicy','Bypass','-File',$PSCommandPath)
Start-Process powershell -Verb RunAs -ArgumentList $args
exit
}
function New-RandomSecret([int]$Length = 32) {
$bytes = New-Object byte[] $Length
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
return ([BitConverter]::ToString($bytes) -replace '-','').ToLower().Substring(0, $Length)
}
function Test-Wsl2 {
try {
$out = wsl.exe --status 2>$null
return ($LASTEXITCODE -eq 0)
} catch { return $false }
}
function Test-Docker {
$cmd = Get-Command docker -ErrorAction SilentlyContinue
if (-not $cmd) { return $false }
docker info *>$null
return ($LASTEXITCODE -eq 0)
}
function Wait-Docker([int]$TimeoutSec = 180) {
Write-Step "Attente du demarrage de Docker Desktop (max ${TimeoutSec}s)..."
$deadline = (Get-Date).AddSeconds($TimeoutSec)
while ((Get-Date) -lt $deadline) {
docker info *>$null
if ($LASTEXITCODE -eq 0) { Write-Ok "Docker repond"; return $true }
Start-Sleep -Seconds 3
}
return $false
}
# ---------------------------------------------------------------------------
# 0. Pre-requis admin
# ---------------------------------------------------------------------------
if (-not (Test-Admin)) { Invoke-Elevated }
Write-Host ""
Write-Host "============================================================"
Write-Host " LoreMindMJ - Installeur Windows" -ForegroundColor Magenta
Write-Host "============================================================"
Write-Host ""
# ---------------------------------------------------------------------------
# 1. WSL2
# ---------------------------------------------------------------------------
Write-Step "Verification de WSL2..."
if (Test-Wsl2) {
Write-Ok "WSL2 deja installe"
} else {
Write-Warn2 "WSL2 absent - installation en cours"
wsl.exe --install --no-launch
Write-Warn2 "REDEMARRAGE REQUIS. Relancez ce script apres reboot."
Read-Host "Appuyez sur Entree pour quitter"
exit 1
}
# ---------------------------------------------------------------------------
# 2. Docker Desktop
# ---------------------------------------------------------------------------
Write-Step "Verification de Docker Desktop..."
if (Test-Docker) {
Write-Ok "Docker fonctionnel"
} else {
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
Write-Err "winget introuvable. Installez Docker Desktop manuellement : https://www.docker.com/products/docker-desktop/"
exit 1
}
Write-Warn2 "Installation de Docker Desktop via winget..."
winget install --id Docker.DockerDesktop -e --accept-package-agreements --accept-source-agreements
if ($LASTEXITCODE -ne 0) { Write-Err "Echec winget"; exit 1 }
Write-Step "Lancement de Docker Desktop..."
$dd = "$env:ProgramFiles\Docker\Docker\Docker Desktop.exe"
if (Test-Path $dd) { Start-Process $dd }
if (-not (Wait-Docker 240)) {
Write-Err "Docker n'a pas demarre. Lancez-le manuellement puis relancez ce script."
exit 1
}
}
# ---------------------------------------------------------------------------
# 3. Dossier d'installation + docker-compose.yml
# ---------------------------------------------------------------------------
Write-Step "Preparation du dossier $InstallDir"
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
Set-Location $InstallDir
$composePath = Join-Path $InstallDir 'docker-compose.yml'
Write-Step "Telechargement de docker-compose.yml"
Invoke-WebRequest -Uri $ComposeUrl -OutFile $composePath -UseBasicParsing
Write-Ok "docker-compose.yml recupere"
# ---------------------------------------------------------------------------
# 4. Generation du .env
# ---------------------------------------------------------------------------
$envPath = Join-Path $InstallDir '.env'
if (Test-Path $envPath) {
Write-Warn2 ".env deja present - sauvegarde en .env.bak"
Copy-Item $envPath "$envPath.bak" -Force
}
Write-Step "Configuration"
$adminUser = if ($NonInteractive) { 'admin' } else {
$r = Read-Host " Nom d'utilisateur admin [admin]"; if ([string]::IsNullOrWhiteSpace($r)) { 'admin' } else { $r }
}
$adminPass = if ($NonInteractive) { New-RandomSecret 16 } else {
$r = Read-Host " Mot de passe admin (vide = genere automatiquement)"
if ([string]::IsNullOrWhiteSpace($r)) { New-RandomSecret 16 } else { $r }
}
$llmProvider = if ($NonInteractive) { 'ollama' } else {
$r = Read-Host " Provider LLM : [ollama] / onemin"
if ($r -eq 'onemin') { 'onemin' } else { 'ollama' }
}
$onemKey = ''
if ($llmProvider -eq 'onemin' -and -not $NonInteractive) {
$onemKey = Read-Host " Cle API 1min.ai"
}
$autoUpdate = if ($NonInteractive) { $true } else {
$r = Read-Host " Activer les mises a jour auto (chaque nuit a 4h) ? [O/n]"
-not ($r -match '^(n|N|no|non)$')
}
$composeProfiles = if ($autoUpdate) { 'autoupdate' } else { '' }
$envContent = @"
# Genere par install.ps1 le $(Get-Date -Format 'yyyy-MM-dd HH:mm')
REGISTRY=git.igmlcreation.fr
TAG=latest
WEB_PORT=$WebPort
POSTGRES_DB=loremind
POSTGRES_USER=loremind
POSTGRES_PASSWORD=$(New-RandomSecret 24)
ADMIN_USERNAME=$adminUser
ADMIN_PASSWORD=$adminPass
BRAIN_INTERNAL_SECRET=$(New-RandomSecret 32)
MINIO_USER=minioadmin
MINIO_PASSWORD=$(New-RandomSecret 24)
LLM_PROVIDER=$llmProvider
OLLAMA_BASE_URL=http://host.docker.internal:11434
LLM_MODEL=gemma4:26b
ONEMIN_API_KEY=$onemKey
ONEMIN_MODEL=gpt-4o-mini
COMPOSE_PROFILES=$composeProfiles
WATCHTOWER_TOKEN=$(New-RandomSecret 32)
WATCHTOWER_MONITOR_ONLY=false
WATCHTOWER_SCHEDULE=0 0 4 * * *
TZ=Europe/Paris
"@
Set-Content -Path $envPath -Value $envContent -Encoding UTF8
Write-Ok ".env genere ($envPath)"
# ---------------------------------------------------------------------------
# 5. Pull + up
# ---------------------------------------------------------------------------
Write-Step "Telechargement des images Docker (peut prendre quelques minutes)"
docker compose pull
if ($LASTEXITCODE -ne 0) { Write-Err "Echec docker compose pull"; exit 1 }
Write-Step "Demarrage de la stack"
docker compose up -d
if ($LASTEXITCODE -ne 0) { Write-Err "Echec docker compose up"; exit 1 }
# ---------------------------------------------------------------------------
# 6. Recap
# ---------------------------------------------------------------------------
$url = "http://localhost:$WebPort"
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " LoreMindMJ est lance !" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
Write-Host " URL : $url"
Write-Host " Identifiant : $adminUser"
Write-Host " Mot de passe : $adminPass"
Write-Host " Dossier : $InstallDir"
if ($autoUpdate) {
Write-Host " Auto-update : active (chaque nuit a 4h via Watchtower)" -ForegroundColor Green
} else {
Write-Host " Auto-update : desactive (mise a jour manuelle uniquement)"
}
Write-Host ""
Write-Host " Commandes utiles (depuis $InstallDir) :"
Write-Host " docker compose ps # etat"
Write-Host " docker compose logs -f # logs"
Write-Host " docker compose down # arret"
Write-Host " docker compose pull && docker compose up -d # mise a jour"
Write-Host ""
Start-Process $url

195
installers/install.sh Normal file
View File

@@ -0,0 +1,195 @@
#!/usr/bin/env bash
# ==========================================================================
# Installeur LoreMindMJ pour Linux (Debian/Ubuntu/Fedora/Arch)
# Usage :
# curl -fsSL https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/installers/install.sh | bash
# ==========================================================================
set -euo pipefail
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/share/loremind}"
COMPOSE_URL="${COMPOSE_URL:-https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/docker-compose.yml}"
WEB_PORT="${WEB_PORT:-8081}"
NON_INTERACTIVE="${NON_INTERACTIVE:-0}"
c_cyan='\033[1;36m'; c_green='\033[1;32m'; c_yellow='\033[1;33m'; c_red='\033[1;31m'; c_off='\033[0m'
step() { echo -e "${c_cyan}==> $*${c_off}"; }
ok() { echo -e " ${c_green}OK${c_off} $*"; }
warn() { echo -e " ${c_yellow}!!${c_off} $*"; }
err() { echo -e " ${c_red}XX${c_off} $*" >&2; }
rand_hex() {
# $1 = nb de caracteres hex
local n="${1:-32}"
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex $((n / 2))
else
head -c $((n * 2)) /dev/urandom | od -An -tx1 | tr -d ' \n' | head -c "$n"
fi
}
ask() {
# ask "prompt" "default"
local prompt="$1" def="${2:-}" reply
if [ "$NON_INTERACTIVE" = "1" ]; then
echo "$def"; return
fi
if [ -n "$def" ]; then
read -r -p " $prompt [$def] " reply </dev/tty || true
else
read -r -p " $prompt " reply </dev/tty || true
fi
echo "${reply:-$def}"
}
detect_pkg() {
if command -v apt-get >/dev/null 2>&1; then echo apt
elif command -v dnf >/dev/null 2>&1; then echo dnf
elif command -v pacman >/dev/null 2>&1; then echo pacman
else echo unknown
fi
}
install_docker() {
step "Installation de Docker..."
local pm; pm="$(detect_pkg)"
case "$pm" in
apt|dnf|pacman)
# Script officiel Docker (gere apt/dnf/pacman)
curl -fsSL https://get.docker.com | sh
;;
*)
err "Gestionnaire de paquets non reconnu. Installez Docker manuellement : https://docs.docker.com/engine/install/"
exit 1
;;
esac
if ! getent group docker >/dev/null; then sudo groupadd docker || true; fi
sudo usermod -aG docker "$USER" || true
sudo systemctl enable --now docker || true
warn "Vous avez ete ajoute au groupe 'docker'. Si docker echoue ensuite, deconnectez-vous puis reconnectez-vous (ou 'newgrp docker')."
}
# ---------------------------------------------------------------------------
echo
echo "============================================================"
echo -e " ${c_cyan}LoreMindMJ - Installeur Linux${c_off}"
echo "============================================================"
echo
# 1. Docker
step "Verification de Docker..."
if ! command -v docker >/dev/null 2>&1; then
install_docker
elif ! docker info >/dev/null 2>&1; then
warn "Docker installe mais inaccessible (daemon arrete ou groupe docker manquant)"
sudo systemctl start docker || true
if ! docker info >/dev/null 2>&1; then
sudo usermod -aG docker "$USER" || true
err "Re-essayez apres 'newgrp docker' ou une nouvelle session."
exit 1
fi
fi
ok "Docker fonctionnel"
# 2. docker compose v2
step "Verification de docker compose..."
if ! docker compose version >/dev/null 2>&1; then
err "Plugin 'docker compose' manquant. Sur Debian/Ubuntu : sudo apt install docker-compose-plugin"
exit 1
fi
ok "docker compose disponible"
# 3. Dossier + compose
step "Preparation du dossier $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
cd "$INSTALL_DIR"
step "Telechargement de docker-compose.yml"
curl -fsSL "$COMPOSE_URL" -o docker-compose.yml
ok "docker-compose.yml recupere"
# 4. .env
if [ -f .env ]; then
warn ".env existant -> sauvegarde en .env.bak"
cp .env .env.bak
fi
step "Configuration"
ADMIN_USERNAME="$(ask "Nom d'utilisateur admin" "admin")"
ADMIN_PASSWORD="$(ask "Mot de passe admin (vide = genere)" "")"
[ -z "$ADMIN_PASSWORD" ] && ADMIN_PASSWORD="$(rand_hex 16)"
LLM_PROVIDER="$(ask "Provider LLM (ollama / onemin)" "ollama")"
ONEMIN_API_KEY=""
if [ "$LLM_PROVIDER" = "onemin" ] && [ "$NON_INTERACTIVE" != "1" ]; then
ONEMIN_API_KEY="$(ask "Cle API 1min.ai" "")"
fi
AUTO_UPDATE_REPLY="$(ask "Activer les mises a jour auto (chaque nuit a 4h) ? [O/n]" "O")"
case "$AUTO_UPDATE_REPLY" in
n|N|no|non|No|Non) COMPOSE_PROFILES="" ; AUTO_UPDATE=0 ;;
*) COMPOSE_PROFILES="autoupdate" ; AUTO_UPDATE=1 ;;
esac
cat > .env <<EOF
# Genere par install.sh le $(date '+%Y-%m-%d %H:%M')
REGISTRY=git.igmlcreation.fr
TAG=latest
WEB_PORT=${WEB_PORT}
POSTGRES_DB=loremind
POSTGRES_USER=loremind
POSTGRES_PASSWORD=$(rand_hex 24)
ADMIN_USERNAME=${ADMIN_USERNAME}
ADMIN_PASSWORD=${ADMIN_PASSWORD}
BRAIN_INTERNAL_SECRET=$(rand_hex 32)
MINIO_USER=minioadmin
MINIO_PASSWORD=$(rand_hex 24)
LLM_PROVIDER=${LLM_PROVIDER}
OLLAMA_BASE_URL=http://host.docker.internal:11434
LLM_MODEL=gemma4:26b
ONEMIN_API_KEY=${ONEMIN_API_KEY}
ONEMIN_MODEL=gpt-4o-mini
COMPOSE_PROFILES=${COMPOSE_PROFILES}
WATCHTOWER_TOKEN=$(rand_hex 32)
WATCHTOWER_MONITOR_ONLY=false
WATCHTOWER_SCHEDULE=0 0 4 * * *
TZ=$(timedatectl show -p Timezone --value 2>/dev/null || echo Europe/Paris)
EOF
chmod 600 .env
ok ".env genere ($INSTALL_DIR/.env)"
# 5. Pull + up
step "Telechargement des images (peut prendre quelques minutes)"
docker compose pull
step "Demarrage de la stack"
docker compose up -d
# 6. Recap
URL="http://localhost:${WEB_PORT}"
echo
echo -e "${c_green}============================================================${c_off}"
echo -e "${c_green} LoreMindMJ est lance !${c_off}"
echo -e "${c_green}============================================================${c_off}"
echo " URL : $URL"
echo " Identifiant : $ADMIN_USERNAME"
echo " Mot de passe : $ADMIN_PASSWORD"
echo " Dossier : $INSTALL_DIR"
if [ "$AUTO_UPDATE" = "1" ]; then
echo -e " Auto-update : ${c_green}active${c_off} (chaque nuit a 4h via Watchtower)"
else
echo " Auto-update : desactive (mise a jour manuelle uniquement)"
fi
echo
echo " Commandes utiles (depuis $INSTALL_DIR) :"
echo " docker compose ps # etat"
echo " docker compose logs -f # logs"
echo " docker compose down # arret"
echo " docker compose pull && docker compose up -d # mise a jour"
echo
if command -v xdg-open >/dev/null 2>&1; then xdg-open "$URL" >/dev/null 2>&1 || true; fi

317
web/e2e/fixtures/api.ts Normal file
View File

@@ -0,0 +1,317 @@
import { APIRequestContext, expect } from '@playwright/test';
export interface SeededLore {
id: string;
name: string;
rootFolderId: string;
rootFolderName: string;
}
/**
* Seed un Lore + un dossier racine via l'API backend.
* Les noms sont uniques (timestamp + random) pour éviter les collisions en parallèle.
*/
export async function seedLoreWithFolder(request: APIRequestContext): Promise<SeededLore> {
const suffix = `${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const loreName = `E2E Lore ${suffix}`;
const folderName = `E2E Folder ${suffix}`;
const loreRes = await request.post('/api/lores', {
data: { name: loreName, description: 'Seeded by Playwright' },
});
expect(loreRes.ok(), `POST /api/lores -> ${loreRes.status()}`).toBeTruthy();
const lore = await loreRes.json();
const folderRes = await request.post('/api/lore-nodes', {
data: { loreId: lore.id, name: folderName, icon: 'folder', description: '' },
});
expect(folderRes.ok(), `POST /api/lore-nodes -> ${folderRes.status()}`).toBeTruthy();
const folder = await folderRes.json();
return { id: lore.id, name: loreName, rootFolderId: folder.id, rootFolderName: folderName };
}
/** Cleanup best-effort — n'échoue pas si déjà supprimé. */
export async function deleteLore(request: APIRequestContext, loreId: string): Promise<void> {
await request.delete(`/api/lores/${loreId}`).catch(() => undefined);
}
export async function getLoreById(
request: APIRequestContext,
loreId: string,
): Promise<{ id: string; name: string; description: string }> {
const res = await request.get(`/api/lores/${loreId}`);
expect(res.ok(), `GET /api/lores/${loreId} -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getArcsForCampaign(
request: APIRequestContext,
campaignId: string,
): Promise<Array<{ id: string; name: string; campaignId: string }>> {
const res = await request.get(`/api/arcs?campaignId=${campaignId}`);
expect(res.ok(), `GET /api/arcs -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getChaptersForArc(
request: APIRequestContext,
arcId: string,
): Promise<Array<{ id: string; name: string; arcId: string }>> {
const res = await request.get(`/api/chapters?arcId=${arcId}`);
expect(res.ok(), `GET /api/chapters -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getScenesForChapter(
request: APIRequestContext,
chapterId: string,
): Promise<Array<{ id: string; name: string; chapterId: string }>> {
const res = await request.get(`/api/scenes?chapterId=${chapterId}`);
expect(res.ok(), `GET /api/scenes -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getTemplatesForLore(
request: APIRequestContext,
loreId: string,
): Promise<Array<{ id: string; name: string }>> {
const res = await request.get(`/api/templates?loreId=${loreId}`);
expect(res.ok(), `GET /api/templates -> ${res.status()}`).toBeTruthy();
return res.json();
}
export interface SeededTemplate {
id: string;
name: string;
}
export async function seedTemplate(
request: APIRequestContext,
opts: { loreId: string; defaultNodeId: string; name?: string; fieldNames?: string[] },
): Promise<SeededTemplate> {
const templateName = opts.name ?? `E2E Template ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const fields = (opts.fieldNames ?? ['Nom', 'Description']).map((name) => ({ name, type: 'TEXT' }));
const res = await request.post('/api/templates', {
data: {
loreId: opts.loreId,
name: templateName,
description: 'Seeded by Playwright',
defaultNodeId: opts.defaultNodeId,
fields,
},
});
expect(res.ok(), `POST /api/templates -> ${res.status()}`).toBeTruthy();
const tpl = await res.json();
return { id: tpl.id, name: templateName };
}
export async function deleteCampaign(request: APIRequestContext, campaignId: string): Promise<void> {
await request.delete(`/api/campaigns/${campaignId}`).catch(() => undefined);
}
export interface SeededCampaign {
id: string;
name: string;
}
export async function seedCampaign(
request: APIRequestContext,
opts: { name?: string; loreId?: string | null; playerCount?: number } = {},
): Promise<SeededCampaign> {
const name = opts.name ?? `E2E Campaign ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const res = await request.post('/api/campaigns', {
data: {
name,
description: 'Seeded by Playwright',
playerCount: opts.playerCount ?? 4,
loreId: opts.loreId ?? null,
gameSystemId: null,
},
});
expect(res.ok(), `POST /api/campaigns -> ${res.status()}`).toBeTruthy();
const c = await res.json();
return { id: c.id, name };
}
export interface SeededArc {
id: string;
name: string;
}
export async function seedArc(
request: APIRequestContext,
opts: { campaignId: string; name?: string; order?: number },
): Promise<SeededArc> {
const name = opts.name ?? `E2E Arc ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const res = await request.post('/api/arcs', {
data: {
campaignId: opts.campaignId,
name,
description: '',
order: opts.order ?? 1,
},
});
expect(res.ok(), `POST /api/arcs -> ${res.status()}`).toBeTruthy();
const a = await res.json();
return { id: a.id, name };
}
export interface SeededChapter {
id: string;
name: string;
}
export async function seedChapter(
request: APIRequestContext,
opts: { arcId: string; name?: string; order?: number },
): Promise<SeededChapter> {
const name = opts.name ?? `E2E Chapter ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const res = await request.post('/api/chapters', {
data: { arcId: opts.arcId, name, description: '', order: opts.order ?? 1 },
});
expect(res.ok(), `POST /api/chapters -> ${res.status()}`).toBeTruthy();
const c = await res.json();
return { id: c.id, name };
}
export async function getChapterById(
request: APIRequestContext,
chapterId: string,
): Promise<{
id: string;
name: string;
description?: string;
gmNotes?: string | null;
playerObjectives?: string | null;
narrativeStakes?: string | null;
}> {
const res = await request.get(`/api/chapters/${chapterId}`);
expect(res.ok(), `GET /api/chapters/${chapterId} -> ${res.status()}`).toBeTruthy();
return res.json();
}
export interface SeededScene {
id: string;
name: string;
}
export async function seedScene(
request: APIRequestContext,
opts: { chapterId: string; name?: string; order?: number },
): Promise<SeededScene> {
const name = opts.name ?? `E2E Scene ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const res = await request.post('/api/scenes', {
data: { chapterId: opts.chapterId, name, description: '', order: opts.order ?? 1 },
});
expect(res.ok(), `POST /api/scenes -> ${res.status()}`).toBeTruthy();
const s = await res.json();
return { id: s.id, name };
}
export async function getSceneById(
request: APIRequestContext,
sceneId: string,
): Promise<{
id: string;
name: string;
description?: string;
location?: string | null;
timing?: string | null;
atmosphere?: string | null;
playerNarration?: string | null;
gmSecretNotes?: string | null;
choicesConsequences?: string | null;
combatDifficulty?: string | null;
enemies?: string | null;
branches?: Array<{ label: string; targetSceneId: string; condition?: string }>;
}> {
const res = await request.get(`/api/scenes/${sceneId}`);
expect(res.ok(), `GET /api/scenes/${sceneId} -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getArcById(
request: APIRequestContext,
arcId: string,
): Promise<{
id: string;
name: string;
description?: string;
themes?: string | null;
stakes?: string | null;
gmNotes?: string | null;
rewards?: string | null;
resolution?: string | null;
}> {
const res = await request.get(`/api/arcs/${arcId}`);
expect(res.ok(), `GET /api/arcs/${arcId} -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getCampaigns(
request: APIRequestContext,
): Promise<Array<{ id: string; name: string; loreId: string | null }>> {
const res = await request.get('/api/campaigns');
expect(res.ok(), `GET /api/campaigns -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getPagesForLore(
request: APIRequestContext,
loreId: string,
): Promise<Array<{ id: string; title: string; nodeId: string; templateId: string }>> {
const res = await request.get(`/api/pages?loreId=${loreId}`);
expect(res.ok(), `GET /api/pages -> ${res.status()}`).toBeTruthy();
return res.json();
}
export interface SeededPage {
id: string;
title: string;
}
export async function seedPage(
request: APIRequestContext,
opts: { loreId: string; nodeId: string; templateId: string; title?: string },
): Promise<SeededPage> {
const title = opts.title ?? `E2E Page ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const res = await request.post('/api/pages', {
data: { loreId: opts.loreId, nodeId: opts.nodeId, templateId: opts.templateId, title },
});
expect(res.ok(), `POST /api/pages -> ${res.status()}`).toBeTruthy();
const page = await res.json();
return { id: page.id, title };
}
export async function getPageById(
request: APIRequestContext,
pageId: string,
): Promise<{
id: string;
title: string;
nodeId: string;
values?: Record<string, string>;
tags?: string[];
notes?: string;
}> {
const res = await request.get(`/api/pages/${pageId}`);
expect(res.ok(), `GET /api/pages/${pageId} -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getTemplateById(
request: APIRequestContext,
templateId: string,
): Promise<{
id: string;
name: string;
description?: string;
defaultNodeId?: string | null;
fields: Array<{ name: string; type: string }>;
}> {
const res = await request.get(`/api/templates/${templateId}`);
expect(res.ok(), `GET /api/templates/${templateId} -> ${res.status()}`).toBeTruthy();
return res.json();
}

View File

@@ -0,0 +1,50 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
deleteCampaign,
getArcById,
type SeededCampaign,
} from '../../fixtures/api';
test.describe('Arc creation', () => {
let campaign: SeededCampaign;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('creates an arc and redirects to its view', async ({ page, request }) => {
const arcName = `Arc ${Date.now()}`;
const description = 'Synopsis test';
await page.goto(`/campaigns/${campaign.id}/arcs/create`);
await expect(page.getByRole('heading', { name: /Créer un nouvel arc/i })).toBeVisible();
await page.getByLabel(/Nom de l'arc/i).fill(arcName);
await page.getByLabel(/Description/i).fill(description);
await page.getByRole('button', { name: /^Créer l'arc$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/\\d+$`));
const createdId = page.url().match(/\/arcs\/(\d+)$/)?.[1];
expect(createdId).toBeTruthy();
const persisted = await getArcById(request, createdId!);
expect(persisted.name).toBe(arcName);
expect(persisted.description).toBe(description);
});
test('submit is disabled when name is empty', async ({ page }) => {
await page.goto(`/campaigns/${campaign.id}/arcs/create`);
const submit = page.getByRole('button', { name: /^Créer l'arc$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/Nom de l'arc/i).fill('Quelque chose');
await expect(submit).toBeEnabled();
});
});

View File

@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
seedArc,
deleteCampaign,
type SeededCampaign,
type SeededArc,
} from '../../fixtures/api';
test.describe('Arc delete', () => {
let campaign: SeededCampaign;
let arc: SeededArc;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
arc = await seedArc(request, { campaignId: campaign.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('deletes an arc after accepting confirm and redirects to the campaign', async ({
page,
request,
}) => {
page.on('dialog', (dialog) => dialog.accept());
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
const res = await request.get(`/api/arcs/${arc.id}`);
expect(res.status()).toBe(404);
});
test('keeps the arc when confirm is dismissed', async ({ page, request }) => {
page.on('dialog', (dialog) => dialog.dismiss());
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/edit$`));
const res = await request.get(`/api/arcs/${arc.id}`);
expect(res.ok()).toBeTruthy();
});
});

View File

@@ -0,0 +1,76 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
seedArc,
deleteCampaign,
getArcById,
type SeededCampaign,
type SeededArc,
} from '../../fixtures/api';
test.describe('Arc edit', () => {
let campaign: SeededCampaign;
let arc: SeededArc;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
arc = await seedArc(request, { campaignId: campaign.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('form is prefilled with the arc name', async ({ page }) => {
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
await expect(page.getByLabel(/Titre de l'arc/i)).toHaveValue(arc.name);
});
test('edits all narrative fields and persists them to API', async ({ page, request }) => {
const newName = `${arc.name} renamed`;
const values = {
description: "Un arc sombre où la trahison s'installe.",
themes: 'Trahison, rédemption, dette de sang.',
stakes: 'La survie du royaume est en jeu.',
gmNotes: 'Révéler le traître en scène 3.',
rewards: 'Relique ancienne + alliance avec le clan nordique.',
resolution: 'Le héros pardonne au traître ou le tue.',
};
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
await page.getByLabel(/Titre de l'arc/i).fill(newName);
await page.getByLabel(/Synopsis de l'arc/i).fill(values.description);
await page.getByLabel(/Thèmes principaux/i).fill(values.themes);
await page.getByLabel(/Enjeux globaux/i).fill(values.stakes);
await page.getByLabel(/Notes et planification du MJ/i).fill(values.gmNotes);
await page.getByLabel(/Récompenses et progression/i).fill(values.rewards);
await page.getByLabel(/Dénouement prévu/i).fill(values.resolution);
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}$`));
const persisted = await getArcById(request, arc.id);
expect(persisted.name).toBe(newName);
expect(persisted.description).toBe(values.description);
expect(persisted.themes).toBe(values.themes);
expect(persisted.stakes).toBe(values.stakes);
expect(persisted.gmNotes).toBe(values.gmNotes);
expect(persisted.rewards).toBe(values.rewards);
expect(persisted.resolution).toBe(values.resolution);
});
test('save button is disabled when name is empty', async ({ page }) => {
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
const nameField = page.getByLabel(/Titre de l'arc/i);
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
await expect(saveBtn).toBeEnabled();
await nameField.fill('');
await expect(saveBtn).toBeDisabled();
await nameField.fill('Valid');
await expect(saveBtn).toBeEnabled();
});
});

View File

@@ -0,0 +1,96 @@
import { test, expect } from '@playwright/test';
import {
seedLoreWithFolder,
deleteLore,
deleteCampaign,
getCampaigns,
type SeededLore,
} from '../../fixtures/api';
test.describe('Campaign creation', () => {
const createdCampaignIds: string[] = [];
let linkedLore: SeededLore;
test.beforeEach(async ({ request }) => {
linkedLore = await seedLoreWithFolder(request);
});
test.afterEach(async ({ request }) => {
while (createdCampaignIds.length) {
await deleteCampaign(request, createdCampaignIds.pop()!);
}
if (linkedLore?.id) await deleteLore(request, linkedLore.id);
});
test('creates a standalone campaign (no lore, no system) and shows it in the grid', async ({
page,
request,
}) => {
const campaignName = `Campagne E2E ${Date.now()}`;
const description = 'Une campagne créée par les tests automatisés.';
await page.goto('/campaigns');
await expect(page.getByRole('heading', { name: /Vos Campagnes|Campagnes/i })).toBeVisible();
await page.locator('.campaign-card.card-new').click();
const modal = page.locator('.modal');
await expect(modal).toBeVisible();
await modal.getByLabel(/Nom de la campagne/i).fill(campaignName);
await modal.getByLabel(/Description/i).fill(description);
await modal.getByLabel(/Nombre de joueurs/i).fill('5');
await modal.getByRole('button', { name: /^Créer la campagne$/i }).click();
await expect(modal).not.toBeVisible();
const newCard = page.locator('.campaign-card', { hasText: campaignName });
await expect(newCard).toBeVisible();
const campaigns = await getCampaigns(request);
const created = campaigns.find((c) => c.name === campaignName);
expect(created).toBeDefined();
expect(created!.loreId).toBeNull();
createdCampaignIds.push(created!.id);
});
test('creates a campaign linked to an existing lore', async ({ page, request }) => {
const campaignName = `Campagne liée ${Date.now()}`;
await page.goto('/campaigns');
await page.locator('.campaign-card.card-new').click();
const modal = page.locator('.modal');
await modal.getByLabel(/Nom de la campagne/i).fill(campaignName);
await modal.getByLabel(/Univers associé/i).selectOption({ label: linkedLore.name });
await modal.getByRole('button', { name: /^Créer la campagne$/i }).click();
await expect(modal).not.toBeVisible();
const campaigns = await getCampaigns(request);
const created = campaigns.find((c) => c.name === campaignName);
expect(created).toBeDefined();
expect(created!.loreId).toBe(linkedLore.id);
createdCampaignIds.push(created!.id);
});
test('submit is disabled without a name and when player count is invalid', async ({ page }) => {
await page.goto('/campaigns');
await page.locator('.campaign-card.card-new').click();
const modal = page.locator('.modal');
const submit = modal.getByRole('button', { name: /^Créer la campagne$/i });
await expect(submit).toBeDisabled();
await modal.getByLabel(/Nom de la campagne/i).fill('Valid name');
await expect(submit).toBeEnabled();
await modal.getByLabel(/Nombre de joueurs/i).fill('0');
await expect(submit).toBeDisabled();
await modal.getByLabel(/Nombre de joueurs/i).fill('3');
await expect(submit).toBeEnabled();
});
});

View File

@@ -0,0 +1,41 @@
import { test, expect } from '@playwright/test';
import { seedCampaign, deleteCampaign, type SeededCampaign } from '../../fixtures/api';
test.describe('Campaign delete', () => {
let campaign: SeededCampaign;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('deletes a campaign after accepting confirm and redirects to the list', async ({
page,
request,
}) => {
page.on('dialog', (dialog) => dialog.accept());
await page.goto(`/campaigns/${campaign.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(/\/campaigns$/);
const res = await request.get(`/api/campaigns/${campaign.id}`);
expect(res.status()).toBe(404);
});
test('keeps the campaign when confirm is dismissed', async ({ page, request }) => {
page.on('dialog', (dialog) => dialog.dismiss());
await page.goto(`/campaigns/${campaign.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
const res = await request.get(`/api/campaigns/${campaign.id}`);
expect(res.ok()).toBeTruthy();
});
});

View File

@@ -0,0 +1,54 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
seedArc,
deleteCampaign,
getChapterById,
type SeededCampaign,
type SeededArc,
} from '../../fixtures/api';
test.describe('Chapter creation', () => {
let campaign: SeededCampaign;
let arc: SeededArc;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
arc = await seedArc(request, { campaignId: campaign.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('creates a chapter and redirects to its view', async ({ page, request }) => {
const chapterName = `Chapitre ${Date.now()}`;
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/create`);
await expect(page.getByRole('heading', { name: /Créer un nouveau chapitre/i })).toBeVisible();
await page.getByLabel(/Nom du chapitre/i).fill(chapterName);
await page.getByLabel(/Description/i).fill('Synopsis du chapitre');
await page.getByRole('button', { name: /^Créer le chapitre$/i }).click();
await expect(page).toHaveURL(
new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/\\d+$`),
);
const createdId = page.url().match(/\/chapters\/(\d+)$/)?.[1];
expect(createdId).toBeTruthy();
const persisted = await getChapterById(request, createdId!);
expect(persisted.name).toBe(chapterName);
});
test('submit is disabled when name is empty', async ({ page }) => {
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/create`);
const submit = page.getByRole('button', { name: /^Créer le chapitre$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/Nom du chapitre/i).fill('OK');
await expect(submit).toBeEnabled();
});
});

View File

@@ -0,0 +1,72 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
seedArc,
seedChapter,
deleteCampaign,
getChapterById,
type SeededCampaign,
type SeededArc,
type SeededChapter,
} from '../../fixtures/api';
test.describe('Chapter edit', () => {
let campaign: SeededCampaign;
let arc: SeededArc;
let chapter: SeededChapter;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
arc = await seedArc(request, { campaignId: campaign.id });
chapter = await seedChapter(request, { arcId: arc.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('edits all chapter fields and persists them to API', async ({ page, request }) => {
const newName = `${chapter.name} renamed`;
const values = {
description: 'Le chapitre ouvre sur un village en proie à la peur.',
gmNotes: 'Le maire cache un pacte avec les gobelins.',
playerObjectives: "Découvrir la source des disparitions.",
narrativeStakes: "La confiance du village est en jeu.",
};
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/edit`);
await expect(page.getByLabel(/Titre du chapitre/i)).toHaveValue(chapter.name);
await page.getByLabel(/Titre du chapitre/i).fill(newName);
await page.getByLabel(/Synopsis du chapitre/i).fill(values.description);
await page.getByLabel(/Notes du Maître de Jeu/i).fill(values.gmNotes);
await page.getByLabel(/Objectifs des joueurs/i).fill(values.playerObjectives);
await page.getByLabel(/Enjeux narratifs/i).fill(values.narrativeStakes);
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(
new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}$`),
);
const persisted = await getChapterById(request, chapter.id);
expect(persisted.name).toBe(newName);
expect(persisted.description).toBe(values.description);
expect(persisted.gmNotes).toBe(values.gmNotes);
expect(persisted.playerObjectives).toBe(values.playerObjectives);
expect(persisted.narrativeStakes).toBe(values.narrativeStakes);
});
test('save button is disabled when name is empty', async ({ page }) => {
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/edit`);
const nameField = page.getByLabel(/Titre du chapitre/i);
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
await expect(saveBtn).toBeEnabled();
await nameField.fill('');
await expect(saveBtn).toBeDisabled();
await nameField.fill('OK');
await expect(saveBtn).toBeEnabled();
});
});

View File

@@ -0,0 +1,64 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
seedArc,
seedChapter,
deleteCampaign,
getSceneById,
type SeededCampaign,
type SeededArc,
type SeededChapter,
} from '../../fixtures/api';
test.describe('Scene creation', () => {
let campaign: SeededCampaign;
let arc: SeededArc;
let chapter: SeededChapter;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
arc = await seedArc(request, { campaignId: campaign.id });
chapter = await seedChapter(request, { arcId: arc.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('creates a scene and redirects to its view', async ({ page, request }) => {
const sceneName = `Scène ${Date.now()}`;
await page.goto(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/create`,
);
await expect(page.getByRole('heading', { name: /Créer une nouvelle scène/i })).toBeVisible();
await page.getByLabel(/Nom de la scène/i).fill(sceneName);
await page.getByLabel(/Description/i).fill('Résumé rapide de la scène.');
await page.getByRole('button', { name: /^Créer la scène$/i }).click();
await expect(page).toHaveURL(
new RegExp(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/\\d+$`,
),
);
const createdId = page.url().match(/\/scenes\/(\d+)$/)?.[1];
expect(createdId).toBeTruthy();
const persisted = await getSceneById(request, createdId!);
expect(persisted.name).toBe(sceneName);
});
test('submit is disabled when name is empty', async ({ page }) => {
await page.goto(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/create`,
);
const submit = page.getByRole('button', { name: /^Créer la scène$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/Nom de la scène/i).fill('OK');
await expect(submit).toBeEnabled();
});
});

View File

@@ -0,0 +1,147 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
seedArc,
seedChapter,
seedScene,
deleteCampaign,
getSceneById,
type SeededCampaign,
type SeededArc,
type SeededChapter,
type SeededScene,
} from '../../fixtures/api';
test.describe('Scene edit', () => {
let campaign: SeededCampaign;
let arc: SeededArc;
let chapter: SeededChapter;
let scene: SeededScene;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
arc = await seedArc(request, { campaignId: campaign.id });
chapter = await seedChapter(request, { arcId: arc.id });
scene = await seedScene(request, { chapterId: chapter.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('edits all text fields across sections and persists them to API', async ({ page, request }) => {
const newName = `${scene.name} renamed`;
const values = {
description: "Les PJ arrivent au village à la nuit tombée.",
location: "Taverne du Dragon d'Or",
timing: 'Soir, pleine lune',
atmosphere: 'Silence pesant, regards fuyants des villageois.',
playerNarration: 'Vous poussez la porte de la taverne…',
gmSecretNotes: 'Le tavernier est complice des bandits.',
choicesConsequences: 'Accepter = piégés à l\'étage. Refuser = filature.',
combatDifficulty: 'Moyenne, 4 bandits niveau 3',
enemies: 'Bandit chef (feuille jointe) + 3 sbires.',
};
await page.goto(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}/edit`,
);
await expect(page.getByLabel(/Titre de la scène/i)).toHaveValue(scene.name);
await page.getByLabel(/Titre de la scène/i).fill(newName);
await page.getByLabel(/Description courte/i).fill(values.description);
await page.getByLabel(/^Lieu$/i).fill(values.location);
await page.getByLabel(/^Moment$/i).fill(values.timing);
await page.getByLabel(/Ambiance et atmosphère/i).fill(values.atmosphere);
// Les sections suivantes sont fermées par défaut : on les ouvre avant de taper.
// Un clic sur le header de la section toggle son état.
await page.locator('app-expandable-section', { hasText: 'Narration pour les joueurs' }).click();
await page.getByPlaceholder(/Le texte que vous lirez aux joueurs/i).fill(values.playerNarration);
await page.locator('app-expandable-section', { hasText: 'Notes et secrets du MJ' }).click();
await page
.getByPlaceholder(/Informations cachées, indices, éléments secrets/i)
.fill(values.gmSecretNotes);
await page.locator('app-expandable-section', { hasText: 'Choix et conséquences' }).click();
await page
.getByPlaceholder(/Décrivez les différentes options/i)
.fill(values.choicesConsequences);
await page.locator('app-expandable-section', { hasText: 'Combat ou rencontre' }).click();
await page.getByLabel(/Difficulté estimée/i).fill(values.combatDifficulty);
await page.getByLabel(/Ennemis et créatures/i).fill(values.enemies);
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(
new RegExp(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}$`,
),
);
const persisted = await getSceneById(request, scene.id);
expect(persisted.name).toBe(newName);
expect(persisted.description).toBe(values.description);
expect(persisted.location).toBe(values.location);
expect(persisted.timing).toBe(values.timing);
expect(persisted.atmosphere).toBe(values.atmosphere);
expect(persisted.playerNarration).toBe(values.playerNarration);
expect(persisted.gmSecretNotes).toBe(values.gmSecretNotes);
expect(persisted.choicesConsequences).toBe(values.choicesConsequences);
expect(persisted.combatDifficulty).toBe(values.combatDifficulty);
expect(persisted.enemies).toBe(values.enemies);
});
test('adds a narrative branch to a sibling scene and persists it', async ({ page, request }) => {
const sibling = await seedScene(request, {
chapterId: chapter.id,
name: 'Scène alternative',
order: 2,
});
await page.goto(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}/edit`,
);
const branchesSection = page.locator('app-expandable-section', { hasText: 'Branches narratives' });
await branchesSection.click();
await branchesSection.getByRole('button', { name: /Ajouter une branche/i }).click();
const branchItem = branchesSection.locator('.branch-item').first();
await branchItem.getByPlaceholder(/Ex: Si les joueurs attaquent/i).fill('Si les PJ fuient');
await branchItem.locator('select').selectOption({ label: sibling.name });
await branchItem.getByPlaceholder(/Jet de Persuasion/i).fill('Sur échec initiative');
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(
new RegExp(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}$`,
),
);
const persisted = await getSceneById(request, scene.id);
expect(persisted.branches).toHaveLength(1);
expect(persisted.branches![0].label).toBe('Si les PJ fuient');
expect(persisted.branches![0].targetSceneId).toBe(sibling.id);
expect(persisted.branches![0].condition).toBe('Sur échec initiative');
});
test('save button is disabled when name is empty', async ({ page }) => {
await page.goto(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}/edit`,
);
const nameField = page.getByLabel(/Titre de la scène/i);
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
await expect(saveBtn).toBeEnabled();
await nameField.fill('');
await expect(saveBtn).toBeDisabled();
await nameField.fill('OK');
await expect(saveBtn).toBeEnabled();
});
});

View File

@@ -0,0 +1,77 @@
import { test, expect } from '@playwright/test';
import { deleteLore } from '../../fixtures/api';
test.describe('Lore creation', () => {
const createdLoreIds: string[] = [];
test.afterEach(async ({ request }) => {
while (createdLoreIds.length) {
const id = createdLoreIds.pop()!;
await deleteLore(request, id);
}
});
test('opens the modal, creates a lore, and shows it in the grid', async ({ page, request }) => {
const loreName = `Univers E2E ${Date.now()}`;
const description = "Un univers créé par les tests automatisés.";
await page.goto('/lore');
await expect(page.getByRole('heading', { name: /Vos Univers/i })).toBeVisible();
await page.locator('.lore-card.card-new').click();
const modal = page.locator('.modal');
await expect(modal).toBeVisible();
await expect(modal.getByRole('heading', { name: /Créer un nouveau Lore/i })).toBeVisible();
await modal.getByLabel(/Nom de l'univers/i).fill(loreName);
await modal.getByLabel('Description').fill(description);
await modal.getByRole('button', { name: /^Créer le lore$/i }).click();
await expect(modal).not.toBeVisible();
const newCard = page.locator('.lore-card', { hasText: loreName });
await expect(newCard).toBeVisible();
await expect(newCard).toContainText(description);
const allLores = await request.get('/api/lores').then((r) => r.json());
const created = allLores.find((l: { name: string; id: string }) => l.name === loreName);
expect(created).toBeDefined();
createdLoreIds.push(created.id);
});
test('submit button is disabled when name is empty', async ({ page }) => {
await page.goto('/lore');
await page.locator('.lore-card.card-new').click();
const modal = page.locator('.modal');
const submit = modal.getByRole('button', { name: /^Créer le lore$/i });
await expect(submit).toBeDisabled();
await modal.getByLabel(/Nom de l'univers/i).fill('Quelque chose');
await expect(submit).toBeEnabled();
await modal.getByLabel(/Nom de l'univers/i).fill('');
await expect(submit).toBeDisabled();
});
test('clicking the backdrop closes the modal without creating', async ({ page }) => {
const typedButAbandoned = `Univers abandonné ${Date.now()}`;
await page.goto('/lore');
await page.locator('.lore-card.card-new').click();
const modal = page.locator('.modal');
await expect(modal).toBeVisible();
await modal.getByLabel(/Nom de l'univers/i).fill(typedButAbandoned);
await page.locator('.modal-backdrop').click({ position: { x: 5, y: 5 } });
await expect(modal).not.toBeVisible();
await expect(page.locator('.lore-card', { hasText: typedButAbandoned })).toHaveCount(0);
});
});

View File

@@ -0,0 +1,53 @@
import { test, expect } from '@playwright/test';
import { seedLoreWithFolder, deleteLore, type SeededLore } from '../../fixtures/api';
test.describe('Lore delete', () => {
let seeded: SeededLore;
test.beforeEach(async ({ request }) => {
seeded = await seedLoreWithFolder(request);
});
test.afterEach(async ({ request }) => {
// Best-effort cleanup — ne fait rien si déjà supprimé par le test.
if (seeded?.id) await deleteLore(request, seeded.id);
});
test('deletes a lore after accepting the confirm and redirects to the list', async ({
page,
request,
}) => {
let confirmMessage = '';
page.on('dialog', async (dialog) => {
confirmMessage = dialog.message();
await dialog.accept();
});
await page.goto(`/lore/${seeded.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
// Attente du dialog et du retour sur la liste des lores.
await expect(page).toHaveURL(/\/lore$/);
expect(confirmMessage).toContain(seeded.name);
// Lore contient un dossier seedé : le récapitulatif doit l'indiquer.
expect(confirmMessage).toMatch(/1 dossier/i);
const res = await request.get(`/api/lores/${seeded.id}`);
expect(res.status()).toBe(404);
});
test('keeps the lore when the confirm is dismissed', async ({ page, request }) => {
page.on('dialog', async (dialog) => {
await dialog.dismiss();
});
await page.goto(`/lore/${seeded.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
// On reste sur le détail, le titre du lore est toujours visible.
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);
const res = await request.get(`/api/lores/${seeded.id}`);
expect(res.ok()).toBeTruthy();
});
});

View File

@@ -0,0 +1,70 @@
import { test, expect } from '@playwright/test';
import { seedLoreWithFolder, deleteLore, getLoreById, type SeededLore } from '../../fixtures/api';
test.describe('Lore inline edit (on detail page)', () => {
let seeded: SeededLore;
test.beforeEach(async ({ request }) => {
seeded = await seedLoreWithFolder(request);
});
test.afterEach(async ({ request }) => {
if (seeded?.id) await deleteLore(request, seeded.id);
});
test('renames the lore inline and persists the change', async ({ page, request }) => {
const newName = `${seeded.name} renamed`;
const newDescription = 'Nouvelle description éditée via UI';
await page.goto(`/lore/${seeded.id}`);
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);
await page.getByRole('button', { name: /^Modifier$/i }).click();
const nameInput = page.getByLabel(/^Nom$/);
const descInput = page.getByLabel(/^Description$/);
await expect(nameInput).toHaveValue(seeded.name);
await nameInput.fill(newName);
await descInput.fill(newDescription);
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
// Sortie du mode édition : le header bascule en mode lecture avec la nouvelle valeur.
await expect(page.locator('.detail-header h1')).toHaveText(newName);
await expect(page.locator('.detail-header .description')).toHaveText(newDescription);
const persisted = await getLoreById(request, seeded.id);
expect(persisted.name).toBe(newName);
expect(persisted.description).toBe(newDescription);
});
test('save button is disabled when name is emptied during edit', async ({ page }) => {
await page.goto(`/lore/${seeded.id}`);
await page.getByRole('button', { name: /^Modifier$/i }).click();
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
await expect(saveBtn).toBeEnabled();
await page.getByLabel(/^Nom$/).fill('');
await expect(saveBtn).toBeDisabled();
await page.getByLabel(/^Nom$/).fill(' ');
await expect(saveBtn).toBeDisabled();
});
test('cancel discards the in-flight edits', async ({ page, request }) => {
await page.goto(`/lore/${seeded.id}`);
await page.getByRole('button', { name: /^Modifier$/i }).click();
await page.getByLabel(/^Nom$/).fill('Name jamais sauvegardé');
await page.getByRole('button', { name: /^Annuler$/i }).click();
// Retour en mode lecture avec le nom d'origine.
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);
const persisted = await getLoreById(request, seeded.id);
expect(persisted.name).toBe(seeded.name);
});
});

View File

@@ -0,0 +1,133 @@
import { test, expect } from '@playwright/test';
import {
seedLoreWithFolder,
seedTemplate,
deleteLore,
getPagesForLore,
type SeededLore,
type SeededTemplate,
} from '../../fixtures/api';
test.describe('Page creation', () => {
let seeded: SeededLore;
let template: SeededTemplate;
test.beforeEach(async ({ request }) => {
seeded = await seedLoreWithFolder(request);
template = await seedTemplate(request, {
loreId: seeded.id,
defaultNodeId: seeded.rootFolderId,
fieldNames: ['Apparence', 'Motivation'],
});
});
test.afterEach(async ({ request }) => {
if (seeded?.id) await deleteLore(request, seeded.id);
});
test('creates an empty page from a template and redirects to edit', async ({ page, request }) => {
const pageTitle = `Maître Eldrin ${Date.now()}`;
await page.goto(`/lore/${seeded.id}/pages/create`);
await expect(page.getByRole('heading', { name: /Créer une nouvelle Page/i })).toBeVisible();
await page.getByLabel(/Titre de la page/i).fill(pageTitle);
await page.locator('.template-card', { hasText: template.name }).click();
await expect(page.locator('.template-card.selected', { hasText: template.name })).toBeVisible();
await expect(page.locator('#page-node')).toHaveValue(seeded.rootFolderId);
await page.getByRole('button', { name: /^Créer la page$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/[^/]+/edit$`));
const pages = await getPagesForLore(request, seeded.id);
const created = pages.find((p) => p.title === pageTitle);
expect(created).toBeDefined();
expect(created?.templateId).toBe(template.id);
expect(created?.nodeId).toBe(seeded.rootFolderId);
});
test('submit is disabled until title, template and folder are set', async ({ page, request }) => {
// On seed un 2ᵉ template pour empêcher l'auto-sélection (qui se déclenche
// quand un seul template a un defaultNodeId valide). Avec deux candidats,
// l'utilisateur doit choisir explicitement → on retrouve le comportement
// initial du test : submit disabled tant qu'un template n'est pas cliqué.
const secondFolderRes = await request.post('/api/lore-nodes', {
data: { loreId: seeded.id, name: 'Autre dossier', icon: 'folder', description: '' },
});
const secondFolderId = (await secondFolderRes.json()).id;
await seedTemplate(request, {
loreId: seeded.id,
defaultNodeId: secondFolderId,
name: `Second template ${Date.now()}`,
});
await page.goto(`/lore/${seeded.id}/pages/create`);
const submit = page.getByRole('button', { name: /^Créer la page$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/Titre de la page/i).fill('Un titre');
await expect(submit).toBeDisabled();
await page.locator('.template-card', { hasText: template.name }).click();
await expect(submit).toBeEnabled();
});
test('entering on a folder-scoped route preselects that folder', async ({ page, request }) => {
const pageTitle = `Page scoped ${Date.now()}`;
// Dossier sans template par défaut → pas d'auto-sélection de template,
// l'utilisateur clique manuellement (ce qu'on veut tester ici).
const secondFolderRes = await request.post('/api/lore-nodes', {
data: { loreId: seeded.id, name: 'Autre dossier', icon: 'folder', description: '' },
});
expect(secondFolderRes.ok()).toBeTruthy();
const secondFolderId = (await secondFolderRes.json()).id;
await page.goto(`/lore/${seeded.id}/nodes/${secondFolderId}/pages/create`);
const nodeSelect = page.locator('#page-node');
await expect(nodeSelect).toHaveValue(secondFolderId);
await expect(nodeSelect).toBeDisabled();
await page.getByLabel(/Titre de la page/i).fill(pageTitle);
await page.locator('.template-card', { hasText: template.name }).click();
await page.getByRole('button', { name: /^Créer la page$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/[^/]+/edit$`));
const pages = await getPagesForLore(request, seeded.id);
expect(pages.find((p) => p.title === pageTitle)?.nodeId).toBe(secondFolderId);
});
test('auto-selects the template on free route when it is the only candidate', async ({ page }) => {
// Le seed donne EXACTEMENT 1 template avec defaultNodeId valide → la
// logique d'auto-sélection doit s'enclencher au chargement.
await page.goto(`/lore/${seeded.id}/pages/create`);
await expect(page.locator('.template-card.selected', { hasText: template.name })).toBeVisible();
await expect(page.locator('#page-node')).toHaveValue(seeded.rootFolderId);
// Conséquence : juste taper un titre suffit pour activer le submit.
const submit = page.getByRole('button', { name: /^Créer la page$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/Titre de la page/i).fill('Auto');
await expect(submit).toBeEnabled();
});
test('auto-selects the template on folder-scoped route when its defaultNodeId matches', async ({
page,
}) => {
// Le template seedé pointe sur seeded.rootFolderId — entrer sur la route
// folder-scoped de ce dossier doit auto-sélectionner ce template.
await page.goto(`/lore/${seeded.id}/nodes/${seeded.rootFolderId}/pages/create`);
await expect(page.locator('.template-card.selected', { hasText: template.name })).toBeVisible();
await expect(page.locator('#page-node')).toHaveValue(seeded.rootFolderId);
await expect(page.locator('#page-node')).toBeDisabled();
});
});

View File

@@ -0,0 +1,58 @@
import { test, expect } from '@playwright/test';
import {
seedLoreWithFolder,
seedTemplate,
seedPage,
deleteLore,
type SeededLore,
type SeededTemplate,
type SeededPage,
} from '../../fixtures/api';
test.describe('Page delete', () => {
let seeded: SeededLore;
let template: SeededTemplate;
let pageEntity: SeededPage;
test.beforeEach(async ({ request }) => {
seeded = await seedLoreWithFolder(request);
template = await seedTemplate(request, {
loreId: seeded.id,
defaultNodeId: seeded.rootFolderId,
});
pageEntity = await seedPage(request, {
loreId: seeded.id,
nodeId: seeded.rootFolderId,
templateId: template.id,
});
});
test.afterEach(async ({ request }) => {
if (seeded?.id) await deleteLore(request, seeded.id);
});
test('deletes the page after accepting confirm', async ({ page, request }) => {
page.on('dialog', (dialog) => dialog.accept());
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
// Le composant redirige vers la racine du Lore après suppression.
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
const res = await request.get(`/api/pages/${pageEntity.id}`);
expect(res.status()).toBe(404);
});
test('keeps the page when confirm is dismissed', async ({ page, request }) => {
page.on('dialog', (dialog) => dialog.dismiss());
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}/edit$`));
const res = await request.get(`/api/pages/${pageEntity.id}`);
expect(res.ok()).toBeTruthy();
});
});

View File

@@ -0,0 +1,95 @@
import { test, expect } from '@playwright/test';
import {
seedLoreWithFolder,
seedTemplate,
seedPage,
deleteLore,
getPageById,
type SeededLore,
type SeededTemplate,
type SeededPage,
} from '../../fixtures/api';
test.describe('Page edit', () => {
let seeded: SeededLore;
let template: SeededTemplate;
let pageEntity: SeededPage;
test.beforeEach(async ({ request }) => {
seeded = await seedLoreWithFolder(request);
template = await seedTemplate(request, {
loreId: seeded.id,
defaultNodeId: seeded.rootFolderId,
fieldNames: ['Apparence', 'Motivation'],
});
pageEntity = await seedPage(request, {
loreId: seeded.id,
nodeId: seeded.rootFolderId,
templateId: template.id,
});
});
test.afterEach(async ({ request }) => {
if (seeded?.id) await deleteLore(request, seeded.id);
});
test('edits title, dynamic fields, notes and persists to API', async ({ page, request }) => {
const newTitle = `Maître Eldrin ${Date.now()}`;
const apparence = 'Vieil homme au regard perçant.';
const motivation = 'Protéger la cité à tout prix.';
const notes = 'MJ : il connaît le secret du roi.';
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
await expect(page.locator('input[name="title"]')).toHaveValue(pageEntity.title);
await page.locator('input[name="title"]').fill(newTitle);
await page.getByPlaceholder('Valeur pour Apparence...').fill(apparence);
await page.getByPlaceholder('Valeur pour Motivation...').fill(motivation);
await page.locator('textarea[name="notes"]').fill(notes);
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}$`));
const persisted = await getPageById(request, pageEntity.id);
expect(persisted.title).toBe(newTitle);
expect(persisted.values?.['Apparence']).toBe(apparence);
expect(persisted.values?.['Motivation']).toBe(motivation);
expect(persisted.notes).toBe(notes);
});
test('adds a tag via chips input and persists it', async ({ page, request }) => {
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
const chipsInput = page.locator('app-chips-input input');
await chipsInput.fill('dangereux');
await chipsInput.press('Enter');
await chipsInput.fill('ancien');
await chipsInput.press('Enter');
await expect(page.locator('app-chips-input').getByText('dangereux')).toBeVisible();
await expect(page.locator('app-chips-input').getByText('ancien')).toBeVisible();
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}$`));
const persisted = await getPageById(request, pageEntity.id);
expect(persisted.tags).toEqual(expect.arrayContaining(['dangereux', 'ancien']));
});
test('save button is disabled when title is empty', async ({ page }) => {
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
const titleInput = page.locator('input[name="title"]');
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
await expect(saveBtn).toBeEnabled();
await titleInput.fill('');
await expect(saveBtn).toBeDisabled();
await titleInput.fill(' ');
await expect(saveBtn).toBeDisabled();
await titleInput.fill('OK');
await expect(saveBtn).toBeEnabled();
});
});

View File

@@ -0,0 +1,72 @@
import { test, expect } from '@playwright/test';
import { seedLoreWithFolder, deleteLore, getTemplatesForLore, type SeededLore } from '../../fixtures/api';
test.describe('Template creation', () => {
let seeded: SeededLore;
test.beforeEach(async ({ request }) => {
seeded = await seedLoreWithFolder(request);
});
test.afterEach(async ({ request }) => {
if (seeded?.id) await deleteLore(request, seeded.id);
});
test('creates a template with default fields', async ({ page, request }) => {
const templateName = `Auberge ${Date.now()}`;
await page.goto(`/lore/${seeded.id}/templates/create`);
await expect(page.getByRole('heading', { name: /Créer un nouveau Template/i })).toBeVisible();
await page.getByLabel(/Nom du template/i).fill(templateName);
await page.getByLabel('Description').fill('Template créé via E2E');
await page.getByLabel(/Dossier par défaut/i).selectOption({ label: seeded.rootFolderName });
await page.getByRole('button', { name: /^Créer le template$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
const templates = await getTemplatesForLore(request, seeded.id);
expect(templates.map((t) => t.name)).toContain(templateName);
});
test('submit is disabled when name is empty', async ({ page }) => {
await page.goto(`/lore/${seeded.id}/templates/create`);
const submit = page.getByRole('button', { name: /^Créer le template$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/Nom du template/i).fill('Valid name');
await page.getByLabel(/Dossier par défaut/i).selectOption({ label: seeded.rootFolderName });
await expect(submit).toBeEnabled();
});
test('can add and remove a custom field before creating', async ({ page, request }) => {
const templateName = `Artefact ${Date.now()}`;
await page.goto(`/lore/${seeded.id}/templates/create`);
await page.getByLabel(/Nom du template/i).fill(templateName);
await page.getByLabel(/Dossier par défaut/i).selectOption({ label: seeded.rootFolderName });
const addFieldInput = page.getByPlaceholder('+ Ajouter un champ');
await addFieldInput.fill('Pouvoir');
await addFieldInput.press('Enter');
await expect(page.locator('.fields-list .field-chip', { hasText: 'Pouvoir' })).toBeVisible();
await addFieldInput.fill('Origine');
await addFieldInput.press('Enter');
await expect(page.locator('.fields-list .field-chip', { hasText: 'Origine' })).toBeVisible();
const origineRow = page.locator('.fields-list .field-row', { hasText: 'Origine' });
await origineRow.getByRole('button', { name: 'Supprimer' }).click();
await expect(page.locator('.fields-list .field-chip', { hasText: 'Origine' })).toHaveCount(0);
await page.getByRole('button', { name: /^Créer le template$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
const templates = await getTemplatesForLore(request, seeded.id);
expect(templates.find((t) => t.name === templateName)).toBeDefined();
});
});

View File

@@ -0,0 +1,51 @@
import { test, expect } from '@playwright/test';
import {
seedLoreWithFolder,
seedTemplate,
deleteLore,
getTemplatesForLore,
type SeededLore,
type SeededTemplate,
} from '../../fixtures/api';
test.describe('Template delete', () => {
let seeded: SeededLore;
let template: SeededTemplate;
test.beforeEach(async ({ request }) => {
seeded = await seedLoreWithFolder(request);
template = await seedTemplate(request, {
loreId: seeded.id,
defaultNodeId: seeded.rootFolderId,
});
});
test.afterEach(async ({ request }) => {
if (seeded?.id) await deleteLore(request, seeded.id);
});
test('deletes the template after accepting confirm', async ({ page, request }) => {
page.on('dialog', (dialog) => dialog.accept());
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
await page.locator('.page-header .btn-danger').click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
const templates = await getTemplatesForLore(request, seeded.id);
expect(templates.find((t) => t.id === template.id)).toBeUndefined();
});
test('keeps the template when confirm is dismissed', async ({ page, request }) => {
page.on('dialog', (dialog) => dialog.dismiss());
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
await page.locator('.page-header .btn-danger').click();
// On reste sur l'écran d'édition (l'URL ne change pas).
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/templates/${template.id}$`));
const templates = await getTemplatesForLore(request, seeded.id);
expect(templates.find((t) => t.id === template.id)).toBeDefined();
});
});

View File

@@ -0,0 +1,74 @@
import { test, expect } from '@playwright/test';
import {
seedLoreWithFolder,
seedTemplate,
deleteLore,
getTemplateById,
type SeededLore,
type SeededTemplate,
} from '../../fixtures/api';
test.describe('Template edit', () => {
let seeded: SeededLore;
let template: SeededTemplate;
test.beforeEach(async ({ request }) => {
seeded = await seedLoreWithFolder(request);
template = await seedTemplate(request, {
loreId: seeded.id,
defaultNodeId: seeded.rootFolderId,
fieldNames: ['Nom', 'Description'],
});
});
test.afterEach(async ({ request }) => {
if (seeded?.id) await deleteLore(request, seeded.id);
});
test('form is prefilled with the current template data', async ({ page }) => {
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
await expect(page.getByLabel(/^Nom$/)).toHaveValue(template.name);
await expect(page.getByLabel(/Dossier par défaut/i)).toHaveValue(seeded.rootFolderId);
await expect(page.locator('.fields-list .field-chip', { hasText: 'Nom' })).toBeVisible();
await expect(page.locator('.fields-list .field-chip', { hasText: 'Description' })).toBeVisible();
});
test('renames the template and persists to API', async ({ page, request }) => {
const newName = `${template.name} renamed`;
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
const nameInput = page.getByLabel(/^Nom$/);
await nameInput.fill(newName);
await page.getByLabel(/Description/i).fill('Description mise à jour');
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
const persisted = await getTemplateById(request, template.id);
expect(persisted.name).toBe(newName);
expect(persisted.description).toBe('Description mise à jour');
});
test('adds a new field, removes an existing one, and persists the order', async ({ page, request }) => {
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
const addInput = page.getByPlaceholder('+ Ajouter un champ');
await addInput.fill('Stats');
await addInput.press('Enter');
await expect(page.locator('.fields-list .field-chip', { hasText: 'Stats' })).toBeVisible();
const descriptionRow = page.locator('.fields-list .field-row', { hasText: 'Description' });
await descriptionRow.getByRole('button', { name: 'Supprimer' }).click();
await expect(page.locator('.fields-list .field-chip', { hasText: 'Description' })).toHaveCount(0);
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
const persisted = await getTemplateById(request, template.id);
const names = persisted.fields.map((f) => f.name);
expect(names).toEqual(['Nom', 'Stats']);
});
});

View File

@@ -0,0 +1,15 @@
import { test, expect } from '@playwright/test';
test.describe('Smoke', () => {
test('app loads without uncaught errors', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (err) => errors.push(err.message));
await page.goto('/');
await expect(page.locator('app-sidebar')).toBeVisible();
await expect(page.locator('main.main-content')).toBeAttached();
expect(errors, `Page errors:\n${errors.join('\n')}`).toEqual([]);
});
});

68
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.4.0", "version": "0.6.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "loremind-web", "name": "loremind-web",
"version": "0.4.0", "version": "0.6.6",
"dependencies": { "dependencies": {
"@angular/animations": "^17.0.0", "@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0", "@angular/common": "^17.0.0",
@@ -27,6 +27,7 @@
"@angular-devkit/build-angular": "^17.0.0", "@angular-devkit/build-angular": "^17.0.0",
"@angular/cli": "^17.0.0", "@angular/cli": "^17.0.0",
"@angular/compiler-cli": "^17.0.0", "@angular/compiler-cli": "^17.0.0",
"@playwright/test": "^1.59.1",
"typescript": "~5.2.2" "typescript": "~5.2.2"
} }
}, },
@@ -3137,6 +3138,22 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
@@ -9063,6 +9080,53 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0" "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
} }
}, },
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.35", "version": "8.4.35",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",

View File

@@ -1,13 +1,17 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.6.1", "version": "0.6.6",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui",
"e2e:headed": "playwright test --headed",
"e2e:report": "playwright show-report"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
@@ -30,6 +34,7 @@
"@angular-devkit/build-angular": "^17.0.0", "@angular-devkit/build-angular": "^17.0.0",
"@angular/cli": "^17.0.0", "@angular/cli": "^17.0.0",
"@angular/compiler-cli": "^17.0.0", "@angular/compiler-cli": "^17.0.0",
"@playwright/test": "^1.59.1",
"typescript": "~5.2.2" "typescript": "~5.2.2"
} }
} }

24
web/playwright.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig, devices } from '@playwright/test';
const baseURL = process.env['E2E_BASE_URL'] || 'http://localhost:8081';
export default defineConfig({
testDir: './e2e/tests',
fullyParallel: true,
forbidOnly: !!process.env['CI'],
retries: process.env['CI'] ? 2 : 0,
workers: process.env['CI'] ? 1 : undefined,
reporter: process.env['CI'] ? [['html', { open: 'never' }], ['list']] : 'html',
use: {
baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

Some files were not shown because too many files have changed in this diff Show More