4 Commits

Author SHA1 Message Date
b0fe8de708 Passage v 0.3.0
All checks were successful
Build & Push Images / build (brain) (push) Successful in 57s
Build & Push Images / build (core) (push) Successful in 1m37s
Build & Push Images / build (web) (push) Successful in 1m22s
2026-04-21 16:56:57 +02:00
71449bee1b Amélioration de l'UI : meilleur affichage des images que ce soit dans la partie lore ou la partie campagne (partie campagne : visualisation scrapbooking). Possibilité de réordonner les champs dans les templates...
Passage v0.3.0
2026-04-21 16:56:27 +02:00
1e34f7f954 Mise en place de tests unitaires et de jacoco pour la partie core 2026-04-21 15:19:45 +02:00
e185dabc45 Mise à jour de l'install.md 2026-04-21 15:01:32 +02:00
61 changed files with 3131 additions and 145 deletions

View File

@@ -1,75 +1,311 @@
# Installation de LoreMindMJ
## Prerequis
Ce document decrit la procedure d'installation de LoreMindMJ. Temps estime :
5 a 10 minutes selon la qualite de la connexion reseau.
- **Docker Desktop** ([Windows](https://www.docker.com/products/docker-desktop/) / [Mac](https://www.docker.com/products/docker-desktop/))
ou **Docker Engine + Compose v2** (Linux).
- (Optionnel) **[Ollama](https://ollama.com/)** si tu veux un LLM local.
Sinon, une cle API [1min.ai](https://1min.ai) suffit.
## 1. Prerequis
## Installation (5 minutes)
1. Telecharge `docker-compose.yml` et `.env.example` depuis la [derniere release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) dans un dossier a toi.
2. Renomme `.env.example` en `.env` et ouvre-le dans un editeur texte. Trois variables sont **obligatoires** :
- `POSTGRES_PASSWORD` : mot de passe de la base (choisis-en un).
- `ADMIN_PASSWORD` : protege l'ecran Parametres de l'appli. Tu le taperas dans une popup du navigateur.
- `BRAIN_INTERNAL_SECRET` : secret interne partage entre les services. Genere une valeur aleatoire :
- **Docker Desktop** ([Windows](https://www.docker.com/products/docker-desktop/) /
[Mac](https://www.docker.com/products/docker-desktop/)) ou
**Docker Engine + Compose v2** (Linux). Verification :
```
openssl rand -hex 32
docker --version
docker compose version
```
(Sous Windows sans openssl : utilise un generateur en ligne type "random hex string 64 chars".)
Compose v2 est requis : la commande est `docker compose`, non `docker-compose`.
Sans ces trois variables, `docker compose up` refusera de demarrer — c'est volontaire pour eviter un deploiement non-securise par defaut.
- **Un fournisseur LLM**, au choix :
- **[Ollama](https://ollama.com/)** installe sur la machine hote (gratuit,
local, necessite environ 6 Go de RAM libre pour les modeles recommandes).
- **Une cle API [1min.ai](https://1min.ai)** (hebergement cloud, facturation
a l'usage, aucune installation supplementaire requise).
3. Dans un terminal, place-toi dans le dossier et lance :
```
docker compose up -d
```
Le premier demarrage telecharge les images (~500 Mo) et initialise la base. Compte 1-2 minutes.
- Environ **2 Go d'espace disque** pour les images Docker, auxquels s'ajoute
la taille des modeles Ollama si l'option locale est retenue.
4. Ouvre http://localhost:8081 dans ton navigateur. Bon jeu !
## 2. Recuperation des fichiers
## Mise a jour
Telecharger les deux fichiers suivants depuis la
[derniere release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) et
les placer dans un dossier dedie (par exemple `~/loremind/` ou
`C:\Programs\loremind\`) :
- `docker-compose.yml`
- `.env.example`
Le code source n'est pas necessaire : les images sont pre-construites et
publiees sur le registry Gitea `git.igmlcreation.fr` (non Docker Hub). Le
premier `docker compose pull` les telechargera automatiquement.
## 3. Configuration du fichier `.env`
Renommer `.env.example` en `.env` et l'ouvrir dans un editeur de texte. **Trois
variables sont obligatoires** ; sans elles, `docker compose up` refusera de
demarrer. Ce comportement est volontaire afin d'eviter tout deploiement
non-securise par defaut.
### `POSTGRES_PASSWORD`
Mot de passe de la base de donnees PostgreSQL. Choisir une valeur robuste.
Seuls les conteneurs utilisent cette valeur : il n'est pas necessaire de la
memoriser au-dela du fichier `.env`.
### `ADMIN_PASSWORD`
Protege l'ecran **Parametres** de l'application via HTTP Basic. Cette valeur
sera demandee par le navigateur lors de toute modification de la configuration
(changement de modele LLM, saisie de cle API, etc.). Le nom d'utilisateur par
defaut est `admin`, modifiable via la variable `ADMIN_USERNAME`.
### `BRAIN_INTERNAL_SECRET`
Secret partage entre le service Java (`core`) et le service Python (`brain`).
Empeche toute requete externe d'atteindre directement le service Brain.
Generer une valeur aleatoire de 64 caracteres hexadecimaux :
```
openssl rand -hex 32
```
Sous Windows sans `openssl`, utiliser PowerShell :
```powershell
-join ((48..57) + (97..102) | Get-Random -Count 64 | % {[char]$_})
```
### Variables optionnelles
- `WEB_PORT` (defaut `8081`) : port d'ecoute de l'interface web.
- `ADMIN_USERNAME` (defaut `admin`) : identifiant de la popup Parametres.
- `LLM_PROVIDER` (defaut `ollama`) : choix du fournisseur LLM (voir
section 5).
Les autres variables (`MINIO_USER`/`MINIO_PASSWORD`, `POSTGRES_DB`,
`POSTGRES_USER`) disposent de valeurs par defaut adaptees a un deploiement
personnel et peuvent etre conservees en l'etat.
## 4. Lancement de la stack
Depuis le dossier contenant `docker-compose.yml` et `.env` :
```
docker compose up -d
```
Le premier demarrage telecharge les images (environ 1 a 2 Go au total) et
initialise la base. Compter 2 a 5 minutes selon la qualite de la connexion.
La progression peut etre suivie via :
```
docker compose logs -f
```
(`Ctrl+C` pour quitter l'affichage ; les services continuent de fonctionner
en arriere-plan.)
Une fois les services en etat `healthy`, ouvrir **http://localhost:8081**
dans un navigateur.
### Verification du fonctionnement
```
docker compose ps
```
Cinq conteneurs doivent apparaitre en etat `Up` ou `healthy` :
`loremind-postgres`, `loremind-minio`, `loremind-core`, `loremind-brain`,
`loremind-web`. Le conteneur `loremind-minio-init` s'arrete automatiquement
apres creation du bucket d'images : ce comportement est normal.
## 5. Configuration du fournisseur LLM
### Ollama (local, gratuit)
Installer Ollama sur la machine hote (pas dans Docker), puis telecharger un
modele :
```
ollama pull gemma4:26b
```
Dans `.env` :
```
LLM_PROVIDER=ollama
LLM_MODEL=gemma4:26b
OLLAMA_BASE_URL=http://host.docker.internal:11434
```
L'adresse `host.docker.internal` permet au conteneur `brain` d'atteindre
Ollama sur la machine hote. Cette resolution est native sous Docker Desktop
(Mac / Windows). Sous Linux, le fichier `docker-compose.yml` declare un
`extra_hosts` equivalent.
### 1min.ai (cloud, paye)
Dans `.env` :
```
LLM_PROVIDER=onemin
ONEMIN_API_KEY=sk-...
ONEMIN_MODEL=gpt-4o-mini
```
### Modification a chaud
Le fournisseur, le modele et la cle API peuvent etre modifies a chaud depuis
l'ecran **Parametres** de l'application. Les modifications sont persistees
dans un volume Docker et survivent aux redemarrages. Les variables d'env du
fichier `.env` sont uniquement utilisees comme valeurs initiales au premier
demarrage.
## 6. Mise a jour
```
docker compose pull
docker compose up -d
```
Les donnees (base Postgres, images MinIO, settings Brain) sont dans des volumes Docker et survivent aux mises a jour.
Les donnees (base PostgreSQL, images MinIO, configuration Brain) sont
stockees dans des volumes Docker et survivent aux mises a jour.
## LLM : Ollama ou 1min.ai ?
## 7. Sauvegarde
**Ollama (local, gratuit)** — Edite `.env` :
```
LLM_PROVIDER=ollama
LLM_MODEL=gemma4:26b
```
Telecharge le modele au prealable : `ollama pull gemma4:26b`.
Les donnees sont reparties dans trois volumes Docker :
**1min.ai (cloud, paye)** — Edite `.env` :
```
LLM_PROVIDER=onemin
ONEMIN_API_KEY=sk-...
ONEMIN_MODEL=open-mistral-nemo
```
- `loremindmj_postgres-data` — ensemble des donnees applicatives (lores,
campagnes, pages, templates, branches narratives, etc.).
- `loremindmj_minio-data` — images uploadees.
- `loremindmj_brain-data` — parametres IA (fournisseur courant, cle API
1min.ai).
Tu peux aussi changer tout ca a chaud depuis l'ecran Parametres de l'appli.
### Export SQL de la base
## Problemes frequents
- **Port 8081 deja pris** : change `WEB_PORT=8082` (ou autre) dans `.env`.
- **Ollama injoignable** : verifie qu'Ollama tourne (`ollama serve`) et que le modele est bien telecharge.
- **"set ADMIN_PASSWORD in .env" / "set BRAIN_INTERNAL_SECRET in .env"** au lancement : tu as oublie une des variables obligatoires de l'etape 2.
- **Popup "Ce site vous demande de vous connecter" sur l'ecran Parametres** : c'est normal. Utilise `admin` (ou ce que tu as mis dans `ADMIN_USERNAME`) et ton `ADMIN_PASSWORD`.
- **Tout casser et repartir de zero** : `docker compose down -v` supprime les volumes (attention, perte de donnees).
## Sauvegarde
Les donnees sont dans les volumes Docker : `loremindmj_postgres-data`, `loremindmj_minio-data`, `loremindmj_brain-data`.
Sauvegarde rapide de la base :
```
docker compose exec postgres pg_dump -U loremind loremind > backup.sql
```
### Sauvegarde complete des volumes
Arreter la stack au prealable afin de garantir la coherence des donnees :
```
docker compose stop
docker run --rm -v loremindmj_postgres-data:/data -v $(pwd):/backup alpine tar czf /backup/postgres-data.tar.gz -C /data .
docker run --rm -v loremindmj_minio-data:/data -v $(pwd):/backup alpine tar czf /backup/minio-data.tar.gz -C /data .
docker compose start
```
Sous Windows PowerShell, remplacer `$(pwd)` par `${PWD}`.
## 8. Resolution des problemes
### Port 8081 deja utilise
Modifier `WEB_PORT=8082` (ou toute autre valeur libre) dans `.env`, puis
relancer :
```
docker compose up -d
```
### Erreur "set POSTGRES_PASSWORD in .env" (ou variable equivalente) au lancement
Une des trois variables obligatoires de l'etape 3 est manquante. Verifier le
contenu du fichier `.env`.
### Popup "Ce site vous demande de vous connecter" sur l'ecran Parametres
Comportement attendu : il s'agit de l'authentification HTTP Basic. Utiliser
la valeur de `ADMIN_USERNAME` (par defaut `admin`) et celle de
`ADMIN_PASSWORD`.
### Erreurs `password authentication failed` en boucle dans les logs Postgres
Si la variable `POSTGRES_PASSWORD` a ete modifiee apres un premier lancement,
le volume Postgres conserve l'ancien mot de passe (initialise une seule fois).
Deux options :
- **Redemarrer avec un volume vierge** (entraine la perte des donnees) :
```
docker compose down -v
docker compose up -d
```
- **Modifier le mot de passe en base** sans toucher au volume :
```
docker compose exec postgres psql -U postgres
```
Puis dans le prompt `psql` :
```sql
ALTER USER loremind WITH PASSWORD 'valeur_exacte_du_env';
\q
```
Redemarrer ensuite le Core : `docker compose restart core`.
### Erreur "502 Bad Gateway" ou message d'erreur IA dans l'interface
Le service Brain ne parvient pas a contacter le fournisseur LLM. Verifier :
- **Ollama** : `ollama serve` est-il actif ? Le modele est-il telecharge
(`ollama list`) ? La valeur de `LLM_MODEL` correspond-elle exactement au
nom d'un modele liste ?
- **1min.ai** : la cle API est-elle valide ? Le modele existe-t-il ?
- Consulter les logs du Brain :
```
docker compose logs brain
```
### Un service ne demarre pas ou reste en etat `unhealthy`
Consulter les logs du service concerne :
```
docker compose logs <service>
```
Services disponibles : `postgres`, `minio`, `core`, `brain`, `web`.
### Redemarrage d'un service apres modification du `.env`
```
docker compose up -d <service>
```
Redemarrage complet : `docker compose restart`.
### Remise a zero complete (PERTE DES DONNEES)
```
docker compose down -v
```
L'option `-v` supprime les volumes. L'ensemble des lores, campagnes, images
et parametres est perdu de maniere definitive.
### "No such image" ou "pull access denied" au premier lancement
Le registry Gitea peut necessiter une authentification selon la visibilite
configuree pour les images. Contacter l'editeur du projet.
## 9. Exposition reseau des services
- **Interface web** : http://localhost:8081 (port configurable via
`WEB_PORT`).
- **PostgreSQL** : accessible uniquement via le reseau Docker interne, non
expose vers l'hote.
- **MinIO** : accessible uniquement via le reseau Docker interne. Les images
transitent par le reverse-proxy Java sur `/api/images/{id}/content`. Le
binding `127.0.0.1:9000/9001` defini dans `docker-compose.override.yml`
n'est actif qu'en developpement.
- **Brain Python** : accessible uniquement via le reseau Docker interne.
Toute requete doit porter l'en-tete `X-Internal-Secret`, injectee
automatiquement par le Core Java et jamais exposee au navigateur.
## 10. Desinstallation
```
docker compose down -v
docker image rm git.igmlcreation.fr/ietm64/core git.igmlcreation.fr/ietm64/brain git.igmlcreation.fr/ietm64/web
```
Supprimer ensuite le dossier contenant `docker-compose.yml` et `.env`.

View File

@@ -67,4 +67,9 @@ docker compose up -d --build
## License
[À définir]
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
En pratique :
- Tu peux l'utiliser gratuitement, l'héberger où tu veux, le modifier, le redistribuer.
- Si tu modifies le code et que tu exposes l'application modifiée sur un réseau (même en SaaS privé), tu dois rendre tes modifications publiques sous la même licence.
- Les univers (Lore) et campagnes que tu crées avec LoreMind **t'appartiennent entièrement** — la licence ne couvre que le code de l'application.

View File

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

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.2.0</version>
<version>0.3.0</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>
@@ -99,6 +99,29 @@
</excludes>
</configuration>
</plugin>
<!-- JaCoCo : rapport de couverture des tests unitaires.
Rapport HTML auto-genere a chaque `mvn test` dans target/site/jacoco/. -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -38,12 +38,18 @@ public class Arc {
private List<String> relatedPageIds = new ArrayList<>();
/**
* IDs des images (Shared Kernel) servant d'illustrations a cet arc.
* IDs des images (Shared Kernel) servant d'illustrations a cet arc (ambiance).
* Galerie ordonnee : la 1ere image est l'illustration principale.
*/
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
/**
* IDs des images utilisees comme cartes / plans (outil de table).
*/
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -34,11 +34,17 @@ public class Chapter {
private List<String> relatedPageIds = new ArrayList<>();
/**
* IDs des images (Shared Kernel) illustrant ce chapitre.
* IDs des images (Shared Kernel) illustrant ce chapitre (ambiance).
*/
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
/**
* IDs des images utilisees comme cartes / plans pour ce chapitre (outil de table).
*/
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -48,11 +48,19 @@ public class Scene {
/**
* IDs des images (Shared Kernel) illustrant cette scene.
* Utile pour carte du lieu, portraits des PNJ principaux, ambiance.
* Vocation "ambiance" : portraits, decors, moodboard. Rendu facon editorial.
*/
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
/**
* IDs des images utilisees comme cartes / plans.
* Vocation "outil de table" : plan de donjon, carte du lieu, schema tactique.
* Rendu different des illustrations : vignettes plus grandes, ratio natif preserve.
*/
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
/**
* Sorties narratives possibles depuis cette scène (graphe intra-chapitre).
* Chaque branche décrit un choix des joueurs et la scène de destination.

View File

@@ -0,0 +1,18 @@
package com.loremind.domain.lorecontext;
/**
* Variante de rendu pour un champ de type IMAGE.
* <p>
* - GALLERY : grille de vignettes (defaut, comportement historique)
* - HERO : premiere image en banniere pleine largeur, suivantes en petit
* - MASONRY : mosaique hauteurs variables facon Pinterest
* - CAROUSEL : defilement horizontal
* <p>
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore pour TEXT.
*/
public enum ImageLayout {
GALLERY,
HERO,
MASONRY,
CAROUSEL
}

View File

@@ -12,10 +12,9 @@ import lombok.NoArgsConstructor;
* Le type pilote le rendu cote front (textarea vs galerie d'images) ET
* la logique metier (seuls les champs TEXT sont envoyes a l'IA pour generation).
* <p>
* Evolution de `List<String> fields` vers `List<TemplateField> fields` :
* refactor propre (DDD Value Object polymorphism) permettant d'ajouter
* facilement d'autres types de champs (DATE, NUMBER, RICH_TEXT...) sans
* casser le contrat.
* Pour les champs IMAGE, {@link #layout} precise la variante de rendu
* (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY.
* Ignore pour les champs TEXT.
*/
@Data
@Builder
@@ -26,14 +25,26 @@ public class TemplateField {
private String name;
/** Type du champ, pilote le rendu et la generation IA. */
private FieldType type;
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
private ImageLayout layout;
/** Constructeur de retrocompat : type seul, layout=null. */
public TemplateField(String name, FieldType type) {
this(name, type, null);
}
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
public static TemplateField text(String name) {
return new TemplateField(name, FieldType.TEXT);
return new TemplateField(name, FieldType.TEXT, null);
}
/** Raccourci : construit un champ de type IMAGE. */
/** Raccourci : construit un champ de type IMAGE avec layout GALLERY. */
public static TemplateField image(String name) {
return new TemplateField(name, FieldType.IMAGE);
return new TemplateField(name, FieldType.IMAGE, ImageLayout.GALLERY);
}
/** Raccourci : construit un champ IMAGE avec un layout specifique. */
public static TemplateField image(String name, ImageLayout layout) {
return new TemplateField(name, FieldType.IMAGE, layout);
}
}

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.ImageLayout;
import com.loremind.domain.lorecontext.TemplateField;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@@ -72,8 +73,20 @@ public class TemplateFieldListJsonConverter
// Type inconnu (ajoute par une version future) : fallback TEXT.
type = FieldType.TEXT;
}
ImageLayout layout = null;
if (type == FieldType.IMAGE) {
String layoutStr = item.path("layout").asText(null);
if (layoutStr != null && !layoutStr.isBlank()) {
try {
layout = ImageLayout.valueOf(layoutStr);
} catch (IllegalArgumentException ex) {
// Layout inconnu : on laisse null → rendu GALLERY par defaut cote UI.
layout = null;
}
}
}
if (name != null && !name.isBlank()) {
result.add(new TemplateField(name, type));
result.add(new TemplateField(name, type, layout));
}
}
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.

View File

@@ -68,6 +68,12 @@ public class ArcJpaEntity {
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
/** IDs des images "cartes / plans". */
@Column(name = "map_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

View File

@@ -57,6 +57,11 @@ public class ChapterJpaEntity {
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
@Column(name = "map_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

View File

@@ -80,6 +80,11 @@ public class SceneJpaEntity {
@Builder.Default
private List<String> illustrationImageIds = new ArrayList<>();
@Column(name = "map_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
// Graphe narratif intra-chapitre : sorties possibles vers d'autres scènes.
// Persisté en TEXT JSON via converter (pattern homogène avec les autres listes).
@Column(name = "branches", columnDefinition = "TEXT")

View File

@@ -83,6 +83,9 @@ public class PostgresArcRepository implements ArcRepository {
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
: new ArrayList<>())
.mapImageIds(jpaEntity.getMapImageIds() != null
? new ArrayList<>(jpaEntity.getMapImageIds())
: new ArrayList<>())
.createdAt(jpaEntity.getCreatedAt())
.updatedAt(jpaEntity.getUpdatedAt())
.build();
@@ -107,6 +110,9 @@ public class PostgresArcRepository implements ArcRepository {
.illustrationImageIds(arc.getIllustrationImageIds() != null
? new ArrayList<>(arc.getIllustrationImageIds())
: new ArrayList<>())
.mapImageIds(arc.getMapImageIds() != null
? new ArrayList<>(arc.getMapImageIds())
: new ArrayList<>())
.createdAt(arc.getCreatedAt())
.updatedAt(arc.getUpdatedAt())
.build();

View File

@@ -80,6 +80,9 @@ public class PostgresChapterRepository implements ChapterRepository {
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
: new ArrayList<>())
.mapImageIds(jpaEntity.getMapImageIds() != null
? new ArrayList<>(jpaEntity.getMapImageIds())
: new ArrayList<>())
.createdAt(jpaEntity.getCreatedAt())
.updatedAt(jpaEntity.getUpdatedAt())
.build();
@@ -102,6 +105,9 @@ public class PostgresChapterRepository implements ChapterRepository {
.illustrationImageIds(chapter.getIllustrationImageIds() != null
? new ArrayList<>(chapter.getIllustrationImageIds())
: new ArrayList<>())
.mapImageIds(chapter.getMapImageIds() != null
? new ArrayList<>(chapter.getMapImageIds())
: new ArrayList<>())
.createdAt(chapter.getCreatedAt())
.updatedAt(chapter.getUpdatedAt())
.build();

View File

@@ -85,6 +85,9 @@ public class PostgresSceneRepository implements SceneRepository {
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
: new ArrayList<>())
.mapImageIds(jpaEntity.getMapImageIds() != null
? new ArrayList<>(jpaEntity.getMapImageIds())
: new ArrayList<>())
.branches(jpaEntity.getBranches() != null
? new ArrayList<>(jpaEntity.getBranches())
: new ArrayList<>())
@@ -115,6 +118,9 @@ public class PostgresSceneRepository implements SceneRepository {
.illustrationImageIds(scene.getIllustrationImageIds() != null
? new ArrayList<>(scene.getIllustrationImageIds())
: new ArrayList<>())
.mapImageIds(scene.getMapImageIds() != null
? new ArrayList<>(scene.getMapImageIds())
: new ArrayList<>())
.branches(scene.getBranches() != null
? new ArrayList<>(scene.getBranches())
: new ArrayList<>())

View File

@@ -79,6 +79,10 @@ public class ImageController {
.contentType(MediaType.parseMediaType(img.getContentType()))
.contentLength(img.getSizeBytes())
.header(HttpHeaders.CACHE_CONTROL, "public, max-age=31536000, immutable")
// Autorise explicitement l'utilisation cross-origin du binaire dans une <img>.
// Sans ce header, Firefox 109+ applique ORB (Opaque Response Blocking) et
// bloque l'image quand le front (localhost:4200) la charge depuis l'API (localhost:8080).
.header("Cross-Origin-Resource-Policy", "cross-origin")
.body(new InputStreamResource(stream));
}

View File

@@ -27,6 +27,9 @@ public class ArcDTO {
/** IDs des pages du Lore liées à cet arc (weak cross-context references). */
private List<String> relatedPageIds = new ArrayList<>();
/** IDs des images (Shared Kernel) illustrant cet arc. */
/** IDs des images (Shared Kernel) illustrant cet arc (ambiance). */
private List<String> illustrationImageIds = new ArrayList<>();
/** IDs des images utilisees comme cartes / plans. */
private List<String> mapImageIds = new ArrayList<>();
}

View File

@@ -25,6 +25,9 @@ public class ChapterDTO {
/** IDs des pages du Lore liées (weak cross-context references). */
private List<String> relatedPageIds = new ArrayList<>();
/** IDs des images (Shared Kernel) illustrant ce chapitre. */
/** IDs des images (Shared Kernel) illustrant ce chapitre (ambiance). */
private List<String> illustrationImageIds = new ArrayList<>();
/** IDs des images utilisees comme cartes / plans. */
private List<String> mapImageIds = new ArrayList<>();
}

View File

@@ -30,9 +30,12 @@ public class SceneDTO {
/** IDs des pages du Lore liées (weak cross-context references). */
private List<String> relatedPageIds = new ArrayList<>();
/** IDs des images (Shared Kernel) illustrant cette scene. */
/** IDs des images (Shared Kernel) illustrant cette scene (ambiance). */
private List<String> illustrationImageIds = new ArrayList<>();
/** IDs des images utilisees comme cartes / plans (outil de table). */
private List<String> mapImageIds = new ArrayList<>();
/** Branches narratives : sorties possibles vers d'autres scènes du même chapitre. */
private List<SceneBranchDTO> branches = new ArrayList<>();
}

View File

@@ -9,6 +9,8 @@ import lombok.NoArgsConstructor;
* <p>
* Miroir wire-friendly de {@link com.loremind.domain.lorecontext.TemplateField}.
* Le type est serialise en string (TEXT/IMAGE) pour interop facile avec Angular.
* Le layout (null pour TEXT, ou GALLERY/HERO/MASONRY/CAROUSEL pour IMAGE) pilote
* le rendu visuel des champs image cote front.
*/
@Data
@NoArgsConstructor
@@ -17,4 +19,11 @@ public class TemplateFieldDTO {
private String name;
/** "TEXT" ou "IMAGE" (string pour serialisation JSON transparente). */
private String type;
/** "GALLERY" | "HERO" | "MASONRY" | "CAROUSEL", null si type=TEXT. */
private String layout;
/** Retrocompat : constructeur sans layout. */
public TemplateFieldDTO(String name, String type) {
this(name, type, null);
}
}

View File

@@ -31,6 +31,7 @@ public class ArcMapper {
dto.setResolution(arc.getResolution());
dto.setRelatedPageIds(copyList(arc.getRelatedPageIds()));
dto.setIllustrationImageIds(copyList(arc.getIllustrationImageIds()));
dto.setMapImageIds(copyList(arc.getMapImageIds()));
return dto;
}
@@ -52,6 +53,7 @@ public class ArcMapper {
.resolution(dto.getResolution())
.relatedPageIds(copyList(dto.getRelatedPageIds()))
.illustrationImageIds(copyList(dto.getIllustrationImageIds()))
.mapImageIds(copyList(dto.getMapImageIds()))
.build();
}

View File

@@ -29,6 +29,7 @@ public class ChapterMapper {
dto.setNarrativeStakes(chapter.getNarrativeStakes());
dto.setRelatedPageIds(copyList(chapter.getRelatedPageIds()));
dto.setIllustrationImageIds(copyList(chapter.getIllustrationImageIds()));
dto.setMapImageIds(copyList(chapter.getMapImageIds()));
return dto;
}
@@ -48,6 +49,7 @@ public class ChapterMapper {
.narrativeStakes(dto.getNarrativeStakes())
.relatedPageIds(copyList(dto.getRelatedPageIds()))
.illustrationImageIds(copyList(dto.getIllustrationImageIds()))
.mapImageIds(copyList(dto.getMapImageIds()))
.build();
}

View File

@@ -41,6 +41,9 @@ public class SceneMapper {
dto.setIllustrationImageIds(scene.getIllustrationImageIds() != null
? new ArrayList<>(scene.getIllustrationImageIds())
: new ArrayList<>());
dto.setMapImageIds(scene.getMapImageIds() != null
? new ArrayList<>(scene.getMapImageIds())
: new ArrayList<>());
dto.setBranches(toBranchDTOs(scene.getBranches()));
return dto;
}
@@ -70,6 +73,9 @@ public class SceneMapper {
.illustrationImageIds(dto.getIllustrationImageIds() != null
? new ArrayList<>(dto.getIllustrationImageIds())
: new ArrayList<>())
.mapImageIds(dto.getMapImageIds() != null
? new ArrayList<>(dto.getMapImageIds())
: new ArrayList<>())
.branches(toBranchDomain(dto.getBranches()))
.build();
}

View File

@@ -1,6 +1,7 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.ImageLayout;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
import org.springframework.stereotype.Component;
@@ -11,6 +12,8 @@ import org.springframework.stereotype.Component;
* <p>
* Tolerance : un type inconnu recu du client est interprete comme TEXT
* (plus safe que de rejeter la requete et d'interrompre la sauvegarde).
* Un layout inconnu ou absent sur un champ IMAGE est interprete comme GALLERY.
* Le layout est force a null pour les champs TEXT.
*/
@Component
public class TemplateFieldMapper {
@@ -18,7 +21,12 @@ public class TemplateFieldMapper {
public TemplateFieldDTO toDTO(TemplateField field) {
if (field == null) return null;
String typeStr = field.getType() != null ? field.getType().name() : FieldType.TEXT.name();
return new TemplateFieldDTO(field.getName(), typeStr);
String layoutStr = null;
if (field.getType() == FieldType.IMAGE) {
ImageLayout layout = field.getLayout() != null ? field.getLayout() : ImageLayout.GALLERY;
layoutStr = layout.name();
}
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr);
}
public TemplateField toDomain(TemplateFieldDTO dto) {
@@ -29,6 +37,16 @@ public class TemplateFieldMapper {
} catch (IllegalArgumentException ex) {
type = FieldType.TEXT;
}
return new TemplateField(dto.getName(), type);
ImageLayout layout = null;
if (type == FieldType.IMAGE) {
try {
layout = dto.getLayout() != null
? ImageLayout.valueOf(dto.getLayout())
: ImageLayout.GALLERY;
} catch (IllegalArgumentException ex) {
layout = ImageLayout.GALLERY;
}
}
return new TemplateField(dto.getName(), type, layout);
}
}

View File

@@ -0,0 +1,175 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour CampaignStructuralContextBuilder.
* Vérifie la projection Campaign Context → Generation Context (arcs → chapitres → scènes),
* le tri par `order`, la résolution des branches via la map id→nom, et le comptage
* null-safe des illustrations.
*/
@ExtendWith(MockitoExtension.class)
public class CampaignStructuralContextBuilderTest {
@Mock
private CampaignRepository campaignRepository;
@Mock
private ArcRepository arcRepository;
@Mock
private ChapterRepository chapterRepository;
@Mock
private SceneRepository sceneRepository;
@InjectMocks
private CampaignStructuralContextBuilder builder;
private Campaign campaign;
@BeforeEach
void setUp() {
campaign = Campaign.builder()
.id("camp-1")
.name("Les Terres Brisées")
.description("Campagne dark fantasy")
.build();
}
@Test
void testBuild_CampaignNotFound() {
when(campaignRepository.findById("missing")).thenReturn(Optional.empty());
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> builder.build("missing"));
assertTrue(ex.getMessage().contains("missing"));
}
@Test
void testBuild_EmptyCampaign() {
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals("Les Terres Brisées", ctx.getCampaignName());
assertEquals("Campagne dark fantasy", ctx.getCampaignDescription());
assertTrue(ctx.getArcs().isEmpty());
}
@Test
void testBuild_SortsArcsChaptersScenesByOrder() {
Arc arc1 = Arc.builder().id("arc-1").name("Arc A").description("first").order(1).build();
Arc arc2 = Arc.builder().id("arc-2").name("Arc B").description("second").order(2).build();
Chapter ch1 = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch A").description("d").order(2).build();
Chapter ch2 = Chapter.builder().id("ch-2").arcId("arc-1").name("Ch B").description("d").order(1).build();
Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Scene A").description("d").order(2).build();
Scene s2 = Scene.builder().id("s-2").chapterId("ch-1").name("Scene B").description("d").order(1).build();
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
// Volontairement inverse pour verifier le tri.
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of(arc2, arc1));
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(ch1, ch2));
when(chapterRepository.findByArcId("arc-2")).thenReturn(List.of());
when(sceneRepository.findByChapterId("ch-1")).thenReturn(List.of(s1, s2));
when(sceneRepository.findByChapterId("ch-2")).thenReturn(List.of());
CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals(2, ctx.getArcs().size());
assertEquals("Arc A", ctx.getArcs().get(0).getName());
assertEquals("Arc B", ctx.getArcs().get(1).getName());
// Chapitres tries : ch2 (order 1) avant ch1 (order 2)
assertEquals(2, ctx.getArcs().get(0).getChapters().size());
assertEquals("Ch B", ctx.getArcs().get(0).getChapters().get(0).getName());
assertEquals("Ch A", ctx.getArcs().get(0).getChapters().get(1).getName());
// Scenes dans ch-1 : s2 (order 1) avant s1 (order 2)
var chADto = ctx.getArcs().get(0).getChapters().get(1);
assertEquals("Scene B", chADto.getScenes().get(0).getName());
assertEquals("Scene A", chADto.getScenes().get(1).getName());
}
@Test
void testBuild_ResolvesBranchTargetSceneName() {
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1).build();
Chapter ch = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch").description("").order(1).build();
SceneBranch validBranch = SceneBranch.builder()
.label("Si les joueurs fuient")
.targetSceneId("s-2")
.condition("en cas de combat perdu")
.build();
SceneBranch danglingBranch = SceneBranch.builder()
.label("Vers l'inconnu")
.targetSceneId("s-inconnu")
.build();
Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Entrée").description("")
.order(1)
.branches(List.of(validBranch, danglingBranch))
.build();
Scene s2 = Scene.builder().id("s-2").chapterId("ch-1").name("Fuite").description("").order(2).build();
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of(arc));
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(ch));
when(sceneRepository.findByChapterId("ch-1")).thenReturn(List.of(s1, s2));
CampaignStructuralContext ctx = builder.build("camp-1");
var scene1Summary = ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0);
assertEquals(2, scene1Summary.getBranches().size());
assertEquals("Fuite", scene1Summary.getBranches().get(0).getTargetSceneName());
assertEquals("en cas de combat perdu", scene1Summary.getBranches().get(0).getCondition());
// ID inconnu → libellé de fallback
assertEquals("(scène inconnue)", scene1Summary.getBranches().get(1).getTargetSceneName());
}
@Test
void testBuild_CountsIllustrationsNullSafe() {
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1)
.illustrationImageIds(List.of("img-1", "img-2")).build();
Chapter ch = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch").description("").order(1)
.illustrationImageIds(null) // null-safe attendu
.build();
Scene s = Scene.builder().id("s-1").chapterId("ch-1").name("S").description("").order(1)
.illustrationImageIds(List.of("img-3"))
.branches(null)
.build();
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of(arc));
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(ch));
when(sceneRepository.findByChapterId("ch-1")).thenReturn(List.of(s));
CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals(2, ctx.getArcs().get(0).getIllustrationCount());
assertEquals(0, ctx.getArcs().get(0).getChapters().get(0).getIllustrationCount());
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getIllustrationCount());
assertTrue(ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().isEmpty());
}
}

View File

@@ -0,0 +1,164 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.GenerationContext;
import com.loremind.domain.generationcontext.GenerationResult;
import com.loremind.domain.generationcontext.ports.AiProvider;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour GeneratePageValuesUseCase.
* Couvre : le happy path (contexte IA correctement assemblé avec uniquement
* les champs TEXT), les erreurs d'intégrité (Page/Template/Lore/Folder
* introuvables), et la validation métier (template sans champ texte).
*/
@ExtendWith(MockitoExtension.class)
public class GeneratePageValuesUseCaseTest {
@Mock private PageRepository pageRepository;
@Mock private TemplateRepository templateRepository;
@Mock private LoreRepository loreRepository;
@Mock private LoreNodeRepository loreNodeRepository;
@Mock private AiProvider aiProvider;
@InjectMocks private GeneratePageValuesUseCase useCase;
private Page page;
private Template template;
private Lore lore;
private LoreNode folder;
@BeforeEach
void setUp() {
page = Page.builder()
.id("p-1").loreId("lore-1").nodeId("node-1").templateId("tpl-1")
.title("Alice")
.build();
template = Template.builder()
.id("tpl-1").loreId("lore-1").name("Personnage")
.fields(List.of(
TemplateField.text("Histoire"),
TemplateField.text("Apparence"),
TemplateField.image("Portrait")))
.build();
lore = Lore.builder().id("lore-1").name("Aetheria").description("monde aérien").build();
folder = LoreNode.builder().id("node-1").name("PNJ").loreId("lore-1").build();
}
@Test
void testExecute_HappyPath_OnlyTextFieldsSentToAi() {
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(template));
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findById("node-1")).thenReturn(Optional.of(folder));
Map<String, String> generated = Map.of(
"Histoire", "Alice est une...",
"Apparence", "Cheveux roux");
when(aiProvider.generatePage(any())).thenReturn(new GenerationResult(generated));
Map<String, String> result = useCase.execute("p-1");
assertEquals(generated, result);
ArgumentCaptor<GenerationContext> captor = ArgumentCaptor.forClass(GenerationContext.class);
verify(aiProvider).generatePage(captor.capture());
GenerationContext ctx = captor.getValue();
assertEquals("Aetheria", ctx.getLoreName());
assertEquals("monde aérien", ctx.getLoreDescription());
assertEquals("PNJ", ctx.getFolderName());
assertEquals("Personnage", ctx.getTemplateName());
assertEquals("Alice", ctx.getPageTitle());
// Seuls les champs TEXT doivent etre envoyes (pas "Portrait" IMAGE).
assertEquals(List.of("Histoire", "Apparence"), ctx.getTemplateFields());
}
@Test
void testExecute_PageNotFound() {
when(pageRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class, () -> useCase.execute("missing"));
verifyNoInteractions(aiProvider);
}
@Test
void testExecute_PageWithoutTemplateId() {
Page orphan = Page.builder().id("p-1").loreId("lore-1").nodeId("node-1")
.templateId(null).title("Orphan").build();
when(pageRepository.findById("p-1")).thenReturn(Optional.of(orphan));
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> useCase.execute("p-1"));
assertTrue(ex.getMessage().contains("template"));
}
@Test
void testExecute_TemplateNotFound() {
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.empty());
assertThrows(IllegalStateException.class, () -> useCase.execute("p-1"));
}
@Test
void testExecute_LoreNotFound() {
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(template));
when(loreRepository.findById("lore-1")).thenReturn(Optional.empty());
assertThrows(IllegalStateException.class, () -> useCase.execute("p-1"));
}
@Test
void testExecute_FolderNotFound() {
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(template));
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findById("node-1")).thenReturn(Optional.empty());
assertThrows(IllegalStateException.class, () -> useCase.execute("p-1"));
}
@Test
void testExecute_TemplateWithoutTextFields() {
Template imageOnly = Template.builder()
.id("tpl-1").loreId("lore-1").name("Galerie")
.fields(List.of(new TemplateField("Portrait", FieldType.IMAGE)))
.build();
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(imageOnly));
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findById("node-1")).thenReturn(Optional.of(folder));
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> useCase.execute("p-1"));
assertTrue(ex.getMessage().contains("Galerie"));
verifyNoInteractions(aiProvider);
}
}

View File

@@ -0,0 +1,193 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour LoreStructuralContextBuilder.
* Couvre la projection LoreContext → GenerationContext : construction du
* dossier→pages, résolution template/relatedPages, troncature des valeurs,
* filtrage des valeurs vides, et extraction unique des tags.
*/
@ExtendWith(MockitoExtension.class)
public class LoreStructuralContextBuilderTest {
@Mock private LoreRepository loreRepository;
@Mock private LoreNodeRepository loreNodeRepository;
@Mock private PageRepository pageRepository;
@Mock private TemplateRepository templateRepository;
@InjectMocks private LoreStructuralContextBuilder builder;
private Lore lore;
@BeforeEach
void setUp() {
lore = Lore.builder().id("lore-1").name("Aetheria").description("Monde aérien").build();
}
@Test
void testBuild_LoreNotFound_ThrowsOnStrict() {
when(loreRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class, () -> builder.build("missing"));
}
@Test
void testBuildOptional_LoreNotFound_ReturnsEmpty() {
when(loreRepository.findById("missing")).thenReturn(Optional.empty());
assertTrue(builder.buildOptional("missing").isEmpty());
}
@Test
void testBuild_EmptyLore() {
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of());
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of());
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of());
LoreStructuralContext ctx = builder.build("lore-1");
assertEquals("Aetheria", ctx.getLoreName());
assertEquals("Monde aérien", ctx.getLoreDescription());
assertTrue(ctx.getFolders().isEmpty());
assertTrue(ctx.getTags().isEmpty());
}
@Test
void testBuild_FoldersAndPagesMapping() {
LoreNode nodePnj = LoreNode.builder().id("n-1").name("PNJ").loreId("lore-1").build();
LoreNode nodeLieux = LoreNode.builder().id("n-2").name("Lieux").loreId("lore-1").build();
Template tpl = Template.builder().id("tpl-1").name("Personnage").build();
Map<String, String> values = new LinkedHashMap<>();
values.put("Histoire", "Il était une fois...");
values.put("VideField", " "); // blank → filtré
values.put("NullField", null); // null → filtré
Page p1 = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1")
.templateId("tpl-1").title("Alice")
.values(values)
.tags(List.of("hero", "magic"))
.relatedPageIds(List.of("p-2", "p-ghost"))
.build();
Page p2 = Page.builder()
.id("p-2").loreId("lore-1").nodeId("n-2")
.templateId("tpl-missing").title("La Forêt")
.values(Map.of())
.tags(List.of("magic"))
.build();
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(nodePnj, nodeLieux));
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(p1, p2));
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(tpl));
LoreStructuralContext ctx = builder.build("lore-1");
assertEquals(2, ctx.getFolders().size());
assertTrue(ctx.getFolders().containsKey("PNJ"));
assertTrue(ctx.getFolders().containsKey("Lieux"));
var pnjPages = ctx.getFolders().get("PNJ");
assertEquals(1, pnjPages.size());
var aliceSummary = pnjPages.get(0);
assertEquals("Alice", aliceSummary.getTitle());
assertEquals("Personnage", aliceSummary.getTemplateName());
// Blank/null filtrés
assertEquals(1, aliceSummary.getValues().size());
assertEquals("Il était une fois...", aliceSummary.getValues().get("Histoire"));
assertEquals(List.of("hero", "magic"), aliceSummary.getTags());
// p-2 resolved into title, p-ghost dropped silently
assertEquals(List.of("La Forêt"), aliceSummary.getRelatedPageTitles());
var forestSummary = ctx.getFolders().get("Lieux").get(0);
// Template introuvable → "?"
assertEquals("?", forestSummary.getTemplateName());
assertTrue(forestSummary.getValues().isEmpty());
assertTrue(forestSummary.getRelatedPageTitles().isEmpty());
// Tags uniques entre les 2 pages
assertEquals(2, ctx.getTags().size());
assertTrue(ctx.getTags().contains("hero"));
assertTrue(ctx.getTags().contains("magic"));
}
@Test
void testBuild_TruncatesLongValues() {
LoreNode node = LoreNode.builder().id("n-1").name("PNJ").build();
Template tpl = Template.builder().id("tpl-1").name("Personnage").build();
String longText = "a".repeat(600); // au-dessus du plafond 500
Map<String, String> values = new HashMap<>();
values.put("Histoire", longText);
Page p = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1")
.templateId("tpl-1").title("Alice")
.values(values)
.build();
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(node));
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(p));
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(tpl));
LoreStructuralContext ctx = builder.build("lore-1");
String truncated = ctx.getFolders().get("PNJ").get(0).getValues().get("Histoire");
assertNotNull(truncated);
assertEquals(500 + 1, truncated.length()); // 500 + ellipse
assertTrue(truncated.endsWith(""));
}
@Test
void testBuild_HandlesNullValuesAndTags() {
LoreNode node = LoreNode.builder().id("n-1").name("PNJ").build();
Page p = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1")
.templateId(null).title("Alice")
.values(null)
.tags(null)
.relatedPageIds(null)
.build();
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(node));
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(p));
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of());
LoreStructuralContext ctx = builder.build("lore-1");
var summary = ctx.getFolders().get("PNJ").get(0);
assertTrue(summary.getValues().isEmpty());
assertTrue(summary.getTags().isEmpty());
assertTrue(summary.getRelatedPageTitles().isEmpty());
}
}

View File

@@ -0,0 +1,130 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour NarrativeEntityContextBuilder.
* Vérifie la projection Arc/Chapter/Scene → NarrativeEntityContext pour
* les 3 types, la normalisation du type (casse/whitespace), la gestion des
* champs null (remplacés par ""), et les erreurs (type inconnu, entité absente).
*/
@ExtendWith(MockitoExtension.class)
public class NarrativeEntityContextBuilderTest {
@Mock private ArcRepository arcRepository;
@Mock private ChapterRepository chapterRepository;
@Mock private SceneRepository sceneRepository;
@InjectMocks private NarrativeEntityContextBuilder builder;
@Test
void testBuild_Arc() {
Arc arc = Arc.builder()
.id("arc-1").name("L'arc sombre").description("synopsis")
.themes("trahison").stakes("vie ou mort").rewards("pouvoir")
.resolution("le roi meurt").gmNotes("secret")
.build();
when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc));
NarrativeEntityContext ctx = builder.build("arc", "arc-1");
assertEquals("arc", ctx.getEntityType());
assertEquals("L'arc sombre", ctx.getTitle());
assertEquals("synopsis", ctx.getFields().get("description (synopsis)"));
assertEquals("trahison", ctx.getFields().get("themes"));
assertEquals("vie ou mort", ctx.getFields().get("stakes"));
assertEquals("pouvoir", ctx.getFields().get("rewards"));
assertEquals("le roi meurt", ctx.getFields().get("resolution"));
assertEquals("secret", ctx.getFields().get("gmNotes"));
}
@Test
void testBuild_Chapter_WithNullFieldsReplacedByEmptyString() {
Chapter ch = Chapter.builder()
.id("ch-1").name("Chapitre 1").description(null)
.playerObjectives(null).narrativeStakes("haut").gmNotes(null)
.build();
when(chapterRepository.findById("ch-1")).thenReturn(Optional.of(ch));
NarrativeEntityContext ctx = builder.build("chapter", "ch-1");
assertEquals("chapter", ctx.getEntityType());
assertEquals("Chapitre 1", ctx.getTitle());
assertEquals("", ctx.getFields().get("description (synopsis)"));
assertEquals("", ctx.getFields().get("playerObjectives"));
assertEquals("haut", ctx.getFields().get("narrativeStakes"));
assertEquals("", ctx.getFields().get("gmNotes"));
}
@Test
void testBuild_Scene_AllFieldsMapped() {
Scene sc = Scene.builder()
.id("s-1").name("L'auberge").description("lieu calme")
.location("Taverne").timing("Soir").atmosphere("tendue")
.playerNarration("Vous entrez...").choicesConsequences("option A...")
.combatDifficulty("moyen").enemies("3 bandits")
.gmSecretNotes("trésor caché")
.build();
when(sceneRepository.findById("s-1")).thenReturn(Optional.of(sc));
NarrativeEntityContext ctx = builder.build("scene", "s-1");
assertEquals("scene", ctx.getEntityType());
assertEquals("L'auberge", ctx.getTitle());
assertEquals("lieu calme", ctx.getFields().get("description"));
assertEquals("Taverne", ctx.getFields().get("location"));
assertEquals("Soir", ctx.getFields().get("timing"));
assertEquals("tendue", ctx.getFields().get("atmosphere"));
assertEquals("Vous entrez...", ctx.getFields().get("playerNarration"));
assertEquals("option A...", ctx.getFields().get("choicesConsequences"));
assertEquals("moyen", ctx.getFields().get("combatDifficulty"));
assertEquals("3 bandits", ctx.getFields().get("enemies"));
assertEquals("trésor caché", ctx.getFields().get("gmSecretNotes"));
}
@Test
void testBuild_NormalizesTypeCaseAndWhitespace() {
Arc arc = Arc.builder().id("arc-1").name("A").build();
when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc));
NarrativeEntityContext ctx = builder.build(" ARC ", "arc-1");
assertEquals("arc", ctx.getEntityType());
}
@Test
void testBuild_UnknownTypeThrows() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> builder.build("npc", "id"));
assertTrue(ex.getMessage().contains("npc"));
}
@Test
void testBuild_NullTypeThrows() {
assertThrows(IllegalArgumentException.class, () -> builder.build(null, "id"));
}
@Test
void testBuild_EntityNotFound() {
when(sceneRepository.findById("missing")).thenReturn(Optional.empty());
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> builder.build("scene", "missing"));
assertTrue(ex.getMessage().contains("missing"));
}
}

View File

@@ -0,0 +1,156 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour StreamChatForCampaignUseCase.
* Vérifie l'orchestration : chargement Campaign, chargement optionnel du
* Lore lié (avec tolérance d'un Lore supprimé), chargement optionnel de
* l'entité narrative focus, et délégation au port AiChatProvider avec la
* ChatRequest correcte.
*/
@ExtendWith(MockitoExtension.class)
public class StreamChatForCampaignUseCaseTest {
@Mock private CampaignRepository campaignRepository;
@Mock private CampaignStructuralContextBuilder campaignContextBuilder;
@Mock private LoreStructuralContextBuilder loreContextBuilder;
@Mock private NarrativeEntityContextBuilder narrativeEntityContextBuilder;
@Mock private AiChatProvider aiChatProvider;
@InjectMocks private StreamChatForCampaignUseCase useCase;
private CampaignStructuralContext campaignCtx;
private List<ChatMessage> messages;
private Consumer<String> onToken;
private Runnable onComplete;
private Consumer<Throwable> onError;
@SuppressWarnings("unchecked")
@BeforeEach
void setUp() {
campaignCtx = CampaignStructuralContext.builder()
.campaignName("X").campaignDescription("d")
.build();
messages = List.of();
onToken = mock(Consumer.class);
onComplete = mock(Runnable.class);
onError = mock(Consumer.class);
}
@Test
void testExecute_CampaignNotFound_Throws() {
when(campaignRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> useCase.execute("missing", null, null, messages, onToken, onComplete, onError));
verifyNoInteractions(aiChatProvider);
}
@Test
void testExecute_StandaloneCampaign_NoLoreNoEntity() {
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
useCase.execute("c-1", null, null, messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), eq(onToken), eq(onComplete), eq(onError));
ChatRequest req = captor.getValue();
assertSame(campaignCtx, req.getCampaignContext());
assertNull(req.getLoreContext());
assertNull(req.getNarrativeEntity());
assertNull(req.getPageContext());
verifyNoInteractions(loreContextBuilder);
verifyNoInteractions(narrativeEntityContextBuilder);
}
@Test
void testExecute_LinkedCampaign_LoadsLoreContext() {
Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-1").build();
LoreStructuralContext loreCtx = LoreStructuralContext.builder()
.loreName("L").loreDescription("d").folders(Collections.emptyMap()).build();
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
when(loreContextBuilder.buildOptional("lore-1")).thenReturn(Optional.of(loreCtx));
useCase.execute("c-1", null, null, messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
assertSame(loreCtx, captor.getValue().getLoreContext());
}
@Test
void testExecute_LinkedCampaignButLoreDeleted_ContinuesWithNullLore() {
Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-ghost").build();
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
when(loreContextBuilder.buildOptional("lore-ghost")).thenReturn(Optional.empty());
useCase.execute("c-1", null, null, messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
assertNull(captor.getValue().getLoreContext());
// La requete doit tout de meme partir (pas d'exception).
}
@Test
void testExecute_WithEntityFocus_BuildsNarrativeEntity() {
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
NarrativeEntityContext entity = NarrativeEntityContext.builder()
.entityType("scene").title("L'auberge").fields(Map.of()).build();
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
when(narrativeEntityContextBuilder.build("scene", "s-1")).thenReturn(entity);
useCase.execute("c-1", "scene", "s-1", messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
assertSame(entity, captor.getValue().getNarrativeEntity());
}
@Test
void testExecute_BlankEntityTypeOrId_NoNarrativeEntityLoaded() {
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
useCase.execute("c-1", "scene", " ", messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
assertNull(captor.getValue().getNarrativeEntity());
verifyNoInteractions(narrativeEntityContextBuilder);
}
}

View File

@@ -0,0 +1,174 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour StreamChatForLoreUseCase.
* Vérifie l'orchestration : chargement du LoreStructuralContext obligatoire,
* construction conditionnelle du PageContext (sans / avec page / page sans
* template), et délégation au port AiChatProvider avec la bonne ChatRequest.
*/
@ExtendWith(MockitoExtension.class)
public class StreamChatForLoreUseCaseTest {
@Mock private LoreStructuralContextBuilder loreContextBuilder;
@Mock private PageRepository pageRepository;
@Mock private TemplateRepository templateRepository;
@Mock private AiChatProvider aiChatProvider;
@InjectMocks private StreamChatForLoreUseCase useCase;
private LoreStructuralContext loreCtx;
private List<ChatMessage> messages;
private Consumer<String> onToken;
private Runnable onComplete;
private Consumer<Throwable> onError;
@SuppressWarnings("unchecked")
@BeforeEach
void setUp() {
loreCtx = LoreStructuralContext.builder()
.loreName("Aetheria").loreDescription("d")
.folders(Collections.emptyMap())
.build();
messages = List.of();
onToken = mock(Consumer.class);
onComplete = mock(Runnable.class);
onError = mock(Consumer.class);
}
@Test
void testExecute_NoPageId_SendsRequestWithoutPageContext() {
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
useCase.execute("lore-1", null, messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), eq(onToken), eq(onComplete), eq(onError));
ChatRequest req = captor.getValue();
assertSame(loreCtx, req.getLoreContext());
assertNull(req.getPageContext());
assertNull(req.getCampaignContext());
}
@Test
void testExecute_BlankPageId_TreatedAsNoPage() {
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
useCase.execute("lore-1", " ", messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
assertNull(captor.getValue().getPageContext());
verifyNoInteractions(pageRepository);
}
@Test
void testExecute_WithPageAndTemplate_BuildsPageContext() {
Template tpl = Template.builder()
.id("tpl-1").name("Personnage")
.fields(List.of(
TemplateField.text("Histoire"),
new TemplateField("Portrait", FieldType.IMAGE)))
.build();
Map<String, String> values = Map.of("Histoire", "...");
Page page = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1")
.templateId("tpl-1").title("Alice")
.values(values)
.build();
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(tpl));
useCase.execute("lore-1", "p-1", messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
ChatRequest req = captor.getValue();
assertNotNull(req.getPageContext());
assertEquals("Alice", req.getPageContext().getTitle());
assertEquals("Personnage", req.getPageContext().getTemplateName());
// Seuls les champs TEXT exposes
assertEquals(List.of("Histoire"), req.getPageContext().getTemplateFields());
assertEquals(values, req.getPageContext().getValues());
}
@Test
void testExecute_PageWithoutTemplate_FallbackContext() {
Page page = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1")
.templateId(null).title("Orphan").values(null)
.build();
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
useCase.execute("lore-1", "p-1", messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
var pageCtx = captor.getValue().getPageContext();
assertNotNull(pageCtx);
assertEquals("Orphan", pageCtx.getTitle());
assertEquals("?", pageCtx.getTemplateName());
assertTrue(pageCtx.getTemplateFields().isEmpty());
assertTrue(pageCtx.getValues().isEmpty());
verifyNoInteractions(templateRepository);
}
@Test
void testExecute_PageWithTemplateIdButTemplateMissing_FallbackToQuestionMark() {
Page page = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1")
.templateId("tpl-ghost").title("Alice").values(Map.of("k", "v"))
.build();
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-ghost")).thenReturn(Optional.empty());
useCase.execute("lore-1", "p-1", messages, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any());
var pageCtx = captor.getValue().getPageContext();
assertEquals("?", pageCtx.getTemplateName());
assertTrue(pageCtx.getTemplateFields().isEmpty());
}
@Test
void testExecute_PageNotFound_Throws() {
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
when(pageRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> useCase.execute("lore-1", "missing", messages, onToken, onComplete, onError));
verifyNoInteractions(aiChatProvider);
}
}

View File

@@ -0,0 +1,198 @@
package com.loremind.application.images;
import com.loremind.domain.images.Image;
import com.loremind.domain.images.ports.ImageRepository;
import com.loremind.domain.images.ports.ImageStorage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour ImageService.
* Couvre : validation upload (filename/MIME/size), happy path upload, compensation
* en cas d'échec DB après upload MinIO réussi, download et delete.
*/
@ExtendWith(MockitoExtension.class)
public class ImageServiceTest {
@Mock private ImageRepository imageRepository;
@Mock private ImageStorage imageStorage;
@InjectMocks private ImageService imageService;
private InputStream data;
@BeforeEach
void setUp() {
data = new ByteArrayInputStream(new byte[]{1, 2, 3});
}
@Test
void testUpload_HappyPath_PersistsMetadata() {
when(imageStorage.upload(eq("portrait.jpg"), eq("image/jpeg"), any(), eq(1024L)))
.thenReturn("images/abc.jpg");
when(imageRepository.save(any(Image.class))).thenAnswer(inv -> {
Image i = inv.getArgument(0);
i.setId("img-1");
return i;
});
Image result = imageService.upload("portrait.jpg", "image/jpeg", data, 1024L);
assertEquals("img-1", result.getId());
assertEquals("images/abc.jpg", result.getStorageKey());
assertNotNull(result.getUploadedAt());
ArgumentCaptor<Image> captor = ArgumentCaptor.forClass(Image.class);
verify(imageRepository).save(captor.capture());
Image saved = captor.getValue();
assertEquals("portrait.jpg", saved.getFilename());
assertEquals("image/jpeg", saved.getContentType());
assertEquals(1024L, saved.getSizeBytes());
}
@Test
void testUpload_NormalizesContentTypeCase() {
when(imageStorage.upload(anyString(), anyString(), any(), anyLong())).thenReturn("k");
when(imageRepository.save(any(Image.class))).thenAnswer(inv -> inv.getArgument(0));
// MIME en majuscules doit etre accepte (normalisation en lowercase lors de la validation)
assertDoesNotThrow(() -> imageService.upload("a.png", "IMAGE/PNG", data, 100L));
}
@Test
void testUpload_DbFailure_CompensatesByDeletingBinary() {
when(imageStorage.upload(anyString(), anyString(), any(), anyLong()))
.thenReturn("images/orphan.jpg");
when(imageRepository.save(any(Image.class))).thenThrow(new RuntimeException("DB down"));
RuntimeException ex = assertThrows(RuntimeException.class,
() -> imageService.upload("a.jpg", "image/jpeg", data, 500L));
assertEquals("DB down", ex.getMessage());
// Compensation : suppression du binaire orphelin
verify(imageStorage).delete("images/orphan.jpg");
}
@Test
void testUpload_BlankFilename_Throws() {
assertThrows(IllegalArgumentException.class,
() -> imageService.upload(" ", "image/jpeg", data, 100L));
verifyNoInteractions(imageStorage);
verifyNoInteractions(imageRepository);
}
@Test
void testUpload_NullFilename_Throws() {
assertThrows(IllegalArgumentException.class,
() -> imageService.upload(null, "image/jpeg", data, 100L));
}
@Test
void testUpload_UnsupportedMime_Throws() {
assertThrows(IllegalArgumentException.class,
() -> imageService.upload("a.pdf", "application/pdf", data, 100L));
verifyNoInteractions(imageStorage);
}
@Test
void testUpload_NullMime_Throws() {
assertThrows(IllegalArgumentException.class,
() -> imageService.upload("a.jpg", null, data, 100L));
}
@Test
void testUpload_ZeroSize_Throws() {
assertThrows(IllegalArgumentException.class,
() -> imageService.upload("a.jpg", "image/jpeg", data, 0L));
}
@Test
void testUpload_NegativeSize_Throws() {
assertThrows(IllegalArgumentException.class,
() -> imageService.upload("a.jpg", "image/jpeg", data, -1L));
}
@Test
void testUpload_TooLarge_Throws() {
long tooBig = 10L * 1024 * 1024 + 1;
assertThrows(IllegalArgumentException.class,
() -> imageService.upload("a.jpg", "image/jpeg", data, tooBig));
verifyNoInteractions(imageStorage);
}
@Test
void testUpload_ExactMaxSize_Accepted() {
long max = 10L * 1024 * 1024;
when(imageStorage.upload(anyString(), anyString(), any(), eq(max))).thenReturn("k");
when(imageRepository.save(any(Image.class))).thenAnswer(inv -> inv.getArgument(0));
assertDoesNotThrow(() -> imageService.upload("a.jpg", "image/jpeg", data, max));
}
@Test
void testGetById_DelegatesToRepository() {
Image img = Image.builder().id("img-1").build();
when(imageRepository.findById("img-1")).thenReturn(Optional.of(img));
assertEquals(Optional.of(img), imageService.getById("img-1"));
}
@Test
void testDownloadById_FoundReturnsStream() {
Image img = Image.builder().id("img-1").storageKey("images/k.jpg").build();
InputStream stream = new ByteArrayInputStream(new byte[]{9});
when(imageRepository.findById("img-1")).thenReturn(Optional.of(img));
when(imageStorage.download("images/k.jpg")).thenReturn(stream);
Optional<InputStream> result = imageService.downloadById("img-1");
assertTrue(result.isPresent());
assertSame(stream, result.get());
}
@Test
void testDownloadById_NotFoundReturnsEmpty() {
when(imageRepository.findById("missing")).thenReturn(Optional.empty());
assertTrue(imageService.downloadById("missing").isEmpty());
verifyNoInteractions(imageStorage);
}
@Test
void testDeleteById_RemovesBinaryThenMetadata() {
Image img = Image.builder().id("img-1").storageKey("images/k.jpg").build();
when(imageRepository.findById("img-1")).thenReturn(Optional.of(img));
imageService.deleteById("img-1");
// Ordre important : binaire d'abord, metadata ensuite.
var order = inOrder(imageStorage, imageRepository);
order.verify(imageStorage).delete("images/k.jpg");
order.verify(imageRepository).deleteById("img-1");
}
@Test
void testDeleteById_NotFound_NoOp() {
when(imageRepository.findById("missing")).thenReturn(Optional.empty());
imageService.deleteById("missing");
verifyNoInteractions(imageStorage);
verify(imageRepository, never()).deleteById(anyString());
}
}

View File

@@ -0,0 +1,125 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour LoreNodeService.
* Vérifie le CRUD, le pattern Parameter Object pour update/create, et
* l'immuabilité de loreId en update.
*/
@ExtendWith(MockitoExtension.class)
public class LoreNodeServiceTest {
@Mock private LoreNodeRepository loreNodeRepository;
@InjectMocks private LoreNodeService loreNodeService;
private LoreNode existing;
@BeforeEach
void setUp() {
existing = LoreNode.builder()
.id("n-1").name("PNJ").icon("users")
.parentId(null).loreId("lore-1")
.build();
}
@Test
void testCreateLoreNode_CopiesChanges() {
LoreNode changes = LoreNode.builder()
.name("Lieux").icon("map-pin").parentId("n-parent").loreId("lore-1")
.build();
when(loreNodeRepository.save(any(LoreNode.class))).thenAnswer(inv -> inv.getArgument(0));
loreNodeService.createLoreNode(changes);
ArgumentCaptor<LoreNode> captor = ArgumentCaptor.forClass(LoreNode.class);
verify(loreNodeRepository).save(captor.capture());
LoreNode saved = captor.getValue();
assertEquals("Lieux", saved.getName());
assertEquals("map-pin", saved.getIcon());
assertEquals("n-parent", saved.getParentId());
assertEquals("lore-1", saved.getLoreId());
}
@Test
void testGetLoreNodeById() {
when(loreNodeRepository.findById("n-1")).thenReturn(Optional.of(existing));
assertTrue(loreNodeService.getLoreNodeById("n-1").isPresent());
}
@Test
void testGetAll_AndByLoreId_AndByParentId() {
when(loreNodeRepository.findAll()).thenReturn(List.of(existing));
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(existing));
when(loreNodeRepository.findByParentId("n-parent")).thenReturn(List.of(existing));
assertEquals(1, loreNodeService.getAllLoreNodes().size());
assertEquals(1, loreNodeService.getLoreNodesByLoreId("lore-1").size());
assertEquals(1, loreNodeService.getLoreNodesByParentId("n-parent").size());
}
@Test
void testSearch_NullOrBlankReturnsEmpty() {
assertTrue(loreNodeService.searchLoreNodes(null).isEmpty());
assertTrue(loreNodeService.searchLoreNodes(" ").isEmpty());
verifyNoInteractions(loreNodeRepository);
}
@Test
void testSearch_TrimsQuery() {
when(loreNodeRepository.searchByName("pnj")).thenReturn(List.of(existing));
loreNodeService.searchLoreNodes(" pnj ");
verify(loreNodeRepository).searchByName("pnj");
}
@Test
void testUpdateLoreNode_AppliesChangesButKeepsLoreId() {
LoreNode changes = LoreNode.builder()
.name("Villes").icon("castle").parentId("n-parent")
.loreId("lore-2") // tentative de migration - doit etre ignoree
.build();
when(loreNodeRepository.findById("n-1")).thenReturn(Optional.of(existing));
when(loreNodeRepository.save(any(LoreNode.class))).thenAnswer(inv -> inv.getArgument(0));
LoreNode result = loreNodeService.updateLoreNode("n-1", changes);
assertEquals("Villes", result.getName());
assertEquals("castle", result.getIcon());
assertEquals("n-parent", result.getParentId());
// loreId doit rester inchange (immutable)
assertEquals("lore-1", result.getLoreId());
}
@Test
void testUpdateLoreNode_NotFoundThrows() {
when(loreNodeRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> loreNodeService.updateLoreNode("missing", existing));
}
@Test
void testDelete() {
loreNodeService.deleteLoreNode("n-1");
verify(loreNodeRepository).deleteById("n-1");
}
}

View File

@@ -0,0 +1,141 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour LoreService.
* Vérifie le CRUD, l'enrichissement à la volée des compteurs nodeCount/pageCount,
* et le comportement défensif de la recherche.
*/
@ExtendWith(MockitoExtension.class)
public class LoreServiceTest {
@Mock private LoreRepository loreRepository;
@Mock private LoreNodeRepository loreNodeRepository;
@Mock private PageRepository pageRepository;
@InjectMocks private LoreService loreService;
private Lore testLore;
@BeforeEach
void setUp() {
testLore = Lore.builder().id("lore-1").name("Aetheria").description("d").build();
}
@Test
void testCreateLore_InitialCountsZero() {
when(loreRepository.save(any(Lore.class))).thenReturn(testLore);
loreService.createLore("Aetheria", "desc");
ArgumentCaptor<Lore> captor = ArgumentCaptor.forClass(Lore.class);
verify(loreRepository).save(captor.capture());
Lore saved = captor.getValue();
assertEquals("Aetheria", saved.getName());
assertEquals("desc", saved.getDescription());
assertEquals(0, saved.getNodeCount());
assertEquals(0, saved.getPageCount());
}
@Test
void testGetLoreById_EnrichesWithCounts() {
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(testLore));
when(loreNodeRepository.countByLoreId("lore-1")).thenReturn(5L);
when(pageRepository.countByLoreId("lore-1")).thenReturn(42L);
Optional<Lore> result = loreService.getLoreById("lore-1");
assertTrue(result.isPresent());
assertEquals(5, result.get().getNodeCount());
assertEquals(42, result.get().getPageCount());
}
@Test
void testGetLoreById_NotFound() {
when(loreRepository.findById("missing")).thenReturn(Optional.empty());
assertTrue(loreService.getLoreById("missing").isEmpty());
verifyNoInteractions(loreNodeRepository);
verifyNoInteractions(pageRepository);
}
@Test
void testGetAllLores_EnrichesEach() {
Lore lore2 = Lore.builder().id("lore-2").name("B").build();
when(loreRepository.findAll()).thenReturn(List.of(testLore, lore2));
when(loreNodeRepository.countByLoreId("lore-1")).thenReturn(3L);
when(loreNodeRepository.countByLoreId("lore-2")).thenReturn(7L);
when(pageRepository.countByLoreId("lore-1")).thenReturn(10L);
when(pageRepository.countByLoreId("lore-2")).thenReturn(20L);
List<Lore> result = loreService.getAllLores();
assertEquals(2, result.size());
assertEquals(3, result.get(0).getNodeCount());
assertEquals(10, result.get(0).getPageCount());
assertEquals(7, result.get(1).getNodeCount());
assertEquals(20, result.get(1).getPageCount());
}
@Test
void testSearchLores_NullOrBlankReturnsEmpty() {
assertTrue(loreService.searchLores(null).isEmpty());
assertTrue(loreService.searchLores(" ").isEmpty());
verifyNoInteractions(loreRepository);
}
@Test
void testSearchLores_TrimsQuery() {
when(loreRepository.searchByName("aet")).thenReturn(List.of(testLore));
List<Lore> result = loreService.searchLores(" aet ");
assertEquals(1, result.size());
verify(loreRepository).searchByName("aet");
}
@Test
void testUpdateLore_Success() {
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(testLore));
when(loreRepository.save(any(Lore.class))).thenAnswer(inv -> inv.getArgument(0));
Lore updated = loreService.updateLore("lore-1", "New Name", "New Desc");
assertEquals("New Name", updated.getName());
assertEquals("New Desc", updated.getDescription());
verify(loreRepository).save(testLore);
}
@Test
void testUpdateLore_NotFoundThrows() {
when(loreRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> loreService.updateLore("missing", "n", "d"));
verify(loreRepository, never()).save(any());
}
@Test
void testDeleteLore_DelegatesToRepository() {
loreService.deleteLore("lore-1");
verify(loreRepository).deleteById("lore-1");
}
}

View File

@@ -0,0 +1,170 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.PageRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour PageService.
* Vérifie la création MVP (collections initialisées vides), le CRUD, les
* copies défensives sur update, l'immuabilité de loreId/templateId, et
* la gestion null-safe des collections.
*/
@ExtendWith(MockitoExtension.class)
public class PageServiceTest {
@Mock private PageRepository pageRepository;
@InjectMocks private PageService pageService;
private Page existing;
@BeforeEach
void setUp() {
existing = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1").templateId("tpl-1")
.title("Alice")
.values(new HashMap<>())
.build();
}
@Test
void testCreatePage_InitializesEmptyCollections() {
when(pageRepository.save(any(Page.class))).thenAnswer(inv -> inv.getArgument(0));
pageService.createPage("lore-1", "n-1", "tpl-1", "Alice");
ArgumentCaptor<Page> captor = ArgumentCaptor.forClass(Page.class);
verify(pageRepository).save(captor.capture());
Page saved = captor.getValue();
assertEquals("Alice", saved.getTitle());
assertEquals("lore-1", saved.getLoreId());
assertEquals("n-1", saved.getNodeId());
assertEquals("tpl-1", saved.getTemplateId());
assertNotNull(saved.getValues());
assertTrue(saved.getValues().isEmpty());
assertNotNull(saved.getTags());
assertTrue(saved.getTags().isEmpty());
assertNotNull(saved.getRelatedPageIds());
assertTrue(saved.getRelatedPageIds().isEmpty());
}
@Test
void testGetById_And_All_And_ByLore_And_ByNode() {
when(pageRepository.findById("p-1")).thenReturn(Optional.of(existing));
when(pageRepository.findAll()).thenReturn(List.of(existing));
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(existing));
when(pageRepository.findByNodeId("n-1")).thenReturn(List.of(existing));
assertTrue(pageService.getPageById("p-1").isPresent());
assertEquals(1, pageService.getAllPages().size());
assertEquals(1, pageService.getPagesByLoreId("lore-1").size());
assertEquals(1, pageService.getPagesByNodeId("n-1").size());
}
@Test
void testSearch_NullOrBlankReturnsEmpty() {
assertTrue(pageService.searchPages(null).isEmpty());
assertTrue(pageService.searchPages(" ").isEmpty());
verifyNoInteractions(pageRepository);
}
@Test
void testSearch_TrimsQuery() {
when(pageRepository.searchByTitle("alice")).thenReturn(List.of(existing));
pageService.searchPages(" alice ");
verify(pageRepository).searchByTitle("alice");
}
@Test
void testUpdatePage_AppliesChangesAndKeepsImmutables() {
Map<String, String> newValues = Map.of("Histoire", "Il...");
Map<String, List<String>> newImages = Map.of("Portrait", List.of("img-1"));
List<String> newTags = List.of("hero");
List<String> newRelated = List.of("p-2");
Page changes = Page.builder()
.loreId("lore-OTHER") // doit etre ignore
.templateId("tpl-OTHER") // doit etre ignore
.nodeId("n-2") // mutable : deplacement
.title("Alice v2")
.values(newValues)
.imageValues(newImages)
.notes("notes MJ")
.tags(newTags)
.relatedPageIds(newRelated)
.build();
when(pageRepository.findById("p-1")).thenReturn(Optional.of(existing));
when(pageRepository.save(any(Page.class))).thenAnswer(inv -> inv.getArgument(0));
Page result = pageService.updatePage("p-1", changes);
assertEquals("Alice v2", result.getTitle());
assertEquals("n-2", result.getNodeId());
// loreId et templateId immuables
assertEquals("lore-1", result.getLoreId());
assertEquals("tpl-1", result.getTemplateId());
assertEquals(newValues, result.getValues());
assertNotSame(newValues, result.getValues()); // copie defensive
assertEquals(newImages, result.getImageValues());
assertNotSame(newImages, result.getImageValues());
assertEquals("notes MJ", result.getNotes());
assertEquals(newTags, result.getTags());
assertNotSame(newTags, result.getTags());
assertEquals(newRelated, result.getRelatedPageIds());
assertNotSame(newRelated, result.getRelatedPageIds());
}
@Test
void testUpdatePage_NullCollectionsBecomeEmpty() {
Page changes = Page.builder()
.nodeId("n-1").title("t")
.values(null).imageValues(null).tags(null).relatedPageIds(null)
.build();
when(pageRepository.findById("p-1")).thenReturn(Optional.of(existing));
when(pageRepository.save(any(Page.class))).thenAnswer(inv -> inv.getArgument(0));
Page result = pageService.updatePage("p-1", changes);
assertNotNull(result.getValues());
assertTrue(result.getValues().isEmpty());
assertNotNull(result.getImageValues());
assertTrue(result.getImageValues().isEmpty());
assertNotNull(result.getTags());
assertTrue(result.getTags().isEmpty());
assertNotNull(result.getRelatedPageIds());
assertTrue(result.getRelatedPageIds().isEmpty());
}
@Test
void testUpdatePage_NotFoundThrows() {
when(pageRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> pageService.updatePage("missing", existing));
}
@Test
void testDelete() {
pageService.deletePage("p-1");
verify(pageRepository).deleteById("p-1");
}
}

View File

@@ -0,0 +1,146 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour TemplateService.
* Vérifie le CRUD, la copie défensive des champs en create/update, le
* comportement null-safe de fields, et l'immuabilité de loreId.
*/
@ExtendWith(MockitoExtension.class)
public class TemplateServiceTest {
@Mock private TemplateRepository templateRepository;
@InjectMocks private TemplateService templateService;
private Template existing;
private List<TemplateField> originalFields;
@BeforeEach
void setUp() {
originalFields = List.of(TemplateField.text("Histoire"), TemplateField.image("Portrait"));
existing = Template.builder()
.id("tpl-1").loreId("lore-1").name("Personnage")
.description("desc").defaultNodeId("n-1")
.fields(List.of(TemplateField.text("Old")))
.build();
}
@Test
void testCreateTemplate_CopiesFieldsDefensively() {
when(templateRepository.save(any(Template.class))).thenAnswer(inv -> inv.getArgument(0));
templateService.createTemplate("lore-1", "T", "d", "n-1", originalFields);
ArgumentCaptor<Template> captor = ArgumentCaptor.forClass(Template.class);
verify(templateRepository).save(captor.capture());
Template saved = captor.getValue();
assertEquals("T", saved.getName());
assertEquals(2, saved.getFields().size());
assertNotSame(originalFields, saved.getFields()); // copie defensive
}
@Test
void testCreateTemplate_NullFieldsBecomesEmptyList() {
when(templateRepository.save(any(Template.class))).thenAnswer(inv -> inv.getArgument(0));
templateService.createTemplate("lore-1", "T", "d", "n-1", null);
ArgumentCaptor<Template> captor = ArgumentCaptor.forClass(Template.class);
verify(templateRepository).save(captor.capture());
assertNotNull(captor.getValue().getFields());
assertTrue(captor.getValue().getFields().isEmpty());
}
@Test
void testGetTemplateById_AndByLoreId_AndAll() {
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(existing));
when(templateRepository.findAll()).thenReturn(List.of(existing));
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(existing));
assertTrue(templateService.getTemplateById("tpl-1").isPresent());
assertEquals(1, templateService.getAllTemplates().size());
assertEquals(1, templateService.getTemplatesByLoreId("lore-1").size());
}
@Test
void testSearch_NullOrBlankReturnsEmpty() {
assertTrue(templateService.searchTemplates(null).isEmpty());
assertTrue(templateService.searchTemplates(" ").isEmpty());
verifyNoInteractions(templateRepository);
}
@Test
void testSearch_TrimsQuery() {
when(templateRepository.searchByName("perso")).thenReturn(List.of(existing));
templateService.searchTemplates(" perso ");
verify(templateRepository).searchByName("perso");
}
@Test
void testUpdateTemplate_AppliesChangesKeepsLoreId() {
Template changes = Template.builder()
.loreId("lore-OTHER") // doit etre ignore
.name("Nouveau").description("nd").defaultNodeId("n-2")
.fields(originalFields)
.build();
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(existing));
when(templateRepository.save(any(Template.class))).thenAnswer(inv -> inv.getArgument(0));
Template result = templateService.updateTemplate("tpl-1", changes);
assertEquals("Nouveau", result.getName());
assertEquals("nd", result.getDescription());
assertEquals("n-2", result.getDefaultNodeId());
assertEquals(2, result.getFields().size());
assertNotSame(originalFields, result.getFields()); // copie defensive
// loreId immuable
assertEquals("lore-1", result.getLoreId());
}
@Test
void testUpdateTemplate_NullFieldsBecomesEmpty() {
Template changes = Template.builder().name("N").description("d")
.defaultNodeId("n-1").fields(null).build();
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(existing));
when(templateRepository.save(any(Template.class))).thenAnswer(inv -> inv.getArgument(0));
Template result = templateService.updateTemplate("tpl-1", changes);
assertNotNull(result.getFields());
assertTrue(result.getFields().isEmpty());
}
@Test
void testUpdateTemplate_NotFoundThrows() {
when(templateRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> templateService.updateTemplate("missing", existing));
}
@Test
void testDelete() {
templateService.deleteTemplate("tpl-1");
verify(templateRepository).deleteById("tpl-1");
}
}

View File

@@ -55,7 +55,7 @@ services:
"
core:
image: ${REGISTRY:-gitea.example.com}/ietm64/core:${TAG:-latest}
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/core:${TAG:-latest}
container_name: loremind-core
depends_on:
postgres:
@@ -77,7 +77,7 @@ services:
restart: unless-stopped
brain:
image: ${REGISTRY:-gitea.example.com}/ietm64/brain:${TAG:-latest}
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/brain:${TAG:-latest}
container_name: loremind-brain
environment:
LLM_PROVIDER: ${LLM_PROVIDER:-ollama}
@@ -95,7 +95,7 @@ services:
restart: unless-stopped
web:
image: ${REGISTRY:-gitea.example.com}/ietm64/web:${TAG:-latest}
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/web:${TAG:-latest}
container_name: loremind-web
depends_on:
- core

View File

@@ -1,6 +1,6 @@
{
"name": "loremind-web",
"version": "0.2.0",
"version": "0.3.0",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",

View File

@@ -18,15 +18,28 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Illustrations (galerie editable) -->
<!-- Illustrations (galerie editable, rendu editorial) -->
<div class="field">
<label>Illustrations</label>
<app-image-gallery
[imageIds]="illustrationImageIds"
[editable]="true"
[layout]="'EDITORIAL'"
(imageIdsChange)="illustrationImageIds = $event">
</app-image-gallery>
<small class="field-hint">Glisse-depose ou clique sur "+ Ajouter" pour uploader. JPEG, PNG, WebP ou GIF, 10 Mo max.</small>
<small class="field-hint">Ambiances, portraits, visuels evocateurs de l'arc. JPEG, PNG, WebP ou GIF, 10 Mo max.</small>
</div>
<!-- Cartes & plans -->
<div class="field">
<label>Cartes &amp; plans</label>
<app-image-gallery
[imageIds]="mapImageIds"
[editable]="true"
[layout]="'MAPS'"
(imageIdsChange)="mapImageIds = $event">
</app-image-gallery>
<small class="field-hint">Cartes regionales et plans utiles aux joueurs pour situer l'action.</small>
</div>
<div class="field">

View File

@@ -60,6 +60,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
/** IDs des images illustrant cet arc (bind sur app-image-gallery editable). */
illustrationImageIds: string[] = [];
/** IDs des images utilisees comme cartes / plans (outil de table). */
mapImageIds: string[] = [];
constructor(
private fb: FormBuilder,
@@ -119,6 +121,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
this.availablePages = pages;
this.relatedPageIds = [...(arc.relatedPageIds ?? [])];
this.illustrationImageIds = [...(arc.illustrationImageIds ?? [])];
this.mapImageIds = [...(arc.mapImageIds ?? [])];
this.pageTitleService.set(arc.name);
this.form.patchValue({
name: arc.name,
@@ -161,7 +164,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
rewards: this.form.value.rewards,
resolution: this.form.value.resolution,
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds
illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
error: () => console.error('Erreur lors de la sauvegarde')

View File

@@ -13,9 +13,15 @@
</div>
</header>
<!-- Illustrations en tete de page (si presentes) -->
<!-- Illustrations (rendu editorial magazine) -->
<section class="view-section" *ngIf="(arc.illustrationImageIds?.length ?? 0) > 0">
<app-image-gallery [imageIds]="arc.illustrationImageIds ?? []"></app-image-gallery>
<app-image-gallery [imageIds]="arc.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section>
<!-- Cartes & plans -->
<section class="view-section" *ngIf="(arc.mapImageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="arc.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
<section class="view-section">

View File

@@ -18,15 +18,28 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Illustrations (galerie editable) -->
<!-- Illustrations (galerie editable, rendu editorial) -->
<div class="field">
<label>Illustrations</label>
<app-image-gallery
[imageIds]="illustrationImageIds"
[editable]="true"
[layout]="'EDITORIAL'"
(imageIdsChange)="illustrationImageIds = $event">
</app-image-gallery>
<small class="field-hint">Ajoute des cartes, portraits ou ambiances pour illustrer ce chapitre.</small>
<small class="field-hint">Portraits, ambiances, scenes marquantes du chapitre.</small>
</div>
<!-- Cartes & plans -->
<div class="field">
<label>Cartes &amp; plans</label>
<app-image-gallery
[imageIds]="mapImageIds"
[editable]="true"
[layout]="'MAPS'"
(imageIdsChange)="mapImageIds = $event">
</app-image-gallery>
<small class="field-hint">Cartes regionales, plans de donjon, schemas utiles a la table.</small>
</div>
<div class="field">

View File

@@ -54,6 +54,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
loreId: string | null = null;
relatedPageIds: string[] = [];
illustrationImageIds: string[] = [];
mapImageIds: string[] = [];
constructor(
private fb: FormBuilder,
@@ -111,6 +112,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
this.availablePages = pages;
this.relatedPageIds = [...(chapter.relatedPageIds ?? [])];
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
this.mapImageIds = [...(chapter.mapImageIds ?? [])];
this.form.patchValue({
name: chapter.name,
description: chapter.description ?? '',
@@ -148,7 +150,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
playerObjectives: this.form.value.playerObjectives,
narrativeStakes: this.form.value.narrativeStakes,
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds
illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]),
error: () => console.error('Erreur lors de la sauvegarde')

View File

@@ -18,9 +18,15 @@
</div>
</header>
<!-- Illustrations -->
<!-- Illustrations (rendu editorial magazine) -->
<section class="view-section" *ngIf="(chapter.illustrationImageIds?.length ?? 0) > 0">
<app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []"></app-image-gallery>
<app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section>
<!-- Cartes & plans -->
<section class="view-section" *ngIf="(chapter.mapImageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="chapter.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
<section class="view-section">

View File

@@ -18,15 +18,28 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Illustrations (galerie editable) -->
<!-- Illustrations (galerie editable, rendu editorial) -->
<div class="field">
<label>Illustrations</label>
<app-image-gallery
[imageIds]="illustrationImageIds"
[editable]="true"
[layout]="'EDITORIAL'"
(imageIdsChange)="illustrationImageIds = $event">
</app-image-gallery>
<small class="field-hint">Carte du lieu, portrait des PNJ presents, ambiance visuelle...</small>
<small class="field-hint">Portraits des PNJ, ambiance visuelle, scenes evocatrices...</small>
</div>
<!-- Cartes & plans (galerie editable, rendu maps) -->
<div class="field">
<label>Cartes &amp; plans</label>
<app-image-gallery
[imageIds]="mapImageIds"
[editable]="true"
[layout]="'MAPS'"
(imageIdsChange)="mapImageIds = $event">
</app-image-gallery>
<small class="field-hint">Plans du lieu, cartes tactiques, schemas utilisables a la table.</small>
</div>
<div class="field">

View File

@@ -53,6 +53,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
loreId: string | null = null;
relatedPageIds: string[] = [];
illustrationImageIds: string[] = [];
mapImageIds: string[] = [];
/** Scènes du chapitre courant (hors scène éditée) — alimente le dropdown des cibles. */
siblingScenes: Scene[] = [];
@@ -129,6 +130,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
this.availablePages = pages;
this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])];
this.mapImageIds = [...(scene.mapImageIds ?? [])];
this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId);
this.branches = (scene.branches ?? []).map(b => ({ ...b }));
this.form.patchValue({
@@ -179,6 +181,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
enemies: this.form.value.enemies,
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds,
branches: this.branches
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]),

View File

@@ -13,9 +13,15 @@
</div>
</header>
<!-- Illustrations -->
<!-- Illustrations (rendu editorial magazine) -->
<section class="view-section" *ngIf="(scene.illustrationImageIds?.length ?? 0) > 0">
<app-image-gallery [imageIds]="scene.illustrationImageIds ?? []"></app-image-gallery>
<app-image-gallery [imageIds]="scene.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section>
<!-- Cartes & plans -->
<section class="view-section" *ngIf="(scene.mapImageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="scene.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
<!-- Description courte -->

View File

@@ -65,6 +65,7 @@
<app-image-gallery
[imageIds]="imageValues[field.name] || []"
[editable]="true"
[layout]="field.layout ?? 'GALLERY'"
(imageIdsChange)="imageValues[field.name] = $event">
</app-image-gallery>
</div>

View File

@@ -28,7 +28,10 @@
</section>
<section class="view-section" *ngIf="field.type === 'IMAGE'">
<h2 class="view-section-title">{{ field.name }}</h2>
<app-image-gallery [imageIds]="imageIdsOf(field.name)"></app-image-gallery>
<app-image-gallery
[imageIds]="imageIdsOf(field.name)"
[layout]="field.layout ?? 'GALLERY'">
</app-image-gallery>
</section>
</ng-container>
</ng-container>

View File

@@ -37,7 +37,21 @@
<label class="section-label">Champs du template *</label>
<ul class="fields-list">
<li class="field-row" *ngFor="let f of fields; let i = index">
<li class="field-row" *ngFor="let f of fields; let i = index; let first = first; let last = last">
<div class="reorder-stack">
<button type="button" class="btn-icon btn-reorder"
(click)="moveField(i, -1)"
[disabled]="first"
aria-label="Monter d'un cran" title="Monter">
<lucide-icon [img]="ChevronUp" [size]="12"></lucide-icon>
</button>
<button type="button" class="btn-icon btn-reorder"
(click)="moveField(i, 1)"
[disabled]="last"
aria-label="Descendre d'un cran" title="Descendre">
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
</button>
</div>
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'">
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
{{ f.name }}
@@ -49,6 +63,17 @@
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
</button>
<select *ngIf="f.type === 'IMAGE'"
class="layout-select"
[ngModel]="f.layout ?? 'GALLERY'"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="setFieldLayout(i, $event)"
title="Mise en page des images">
<option value="GALLERY">Grille</option>
<option value="HERO">Heros</option>
<option value="MASONRY">Mosaique</option>
<option value="CAROUSEL">Carrousel</option>
</select>
<button type="button" class="btn-icon" (click)="removeField(i)" aria-label="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>

View File

@@ -124,7 +124,8 @@
&:hover { background: #363650; color: white; }
}
.type-select {
.type-select,
.layout-select {
background: #1a1a2e;
border: 1px solid #2a2a3d;
color: white;
@@ -137,6 +138,12 @@
&:focus { outline: none; border-color: #6c63ff; }
}
.layout-select {
height: 28px;
font-size: 0.72rem;
padding: 0 0.45rem;
}
input {
flex: 1;
background: #1a1a2e;
@@ -153,6 +160,35 @@
}
&.add-row { margin-top: 0.5rem; }
.reorder-stack {
display: flex;
flex-direction: column;
gap: 2px;
.btn-reorder {
width: 22px;
height: 16px;
background: transparent;
color: #6b7280;
border: 1px solid #2a2a3d;
border-radius: 3px;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.12s, color 0.12s, border-color 0.12s;
&:hover:not(:disabled) {
background: #2a2a3d;
color: white;
border-color: #6c63ff;
}
&:disabled { opacity: 0.3; cursor: not-allowed; }
}
}
}
.btn-icon {

View File

@@ -2,13 +2,13 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon } from 'lucide-angular';
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service';
import { LoreNode } from '../../services/lore.model';
import { FieldType, TemplateField } from '../../services/template.model';
import { FieldType, ImageLayout, TemplateField } from '../../services/template.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
/**
@@ -29,6 +29,8 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2;
readonly Type = Type;
readonly ImageIcon = ImageIcon;
readonly ChevronUp = ChevronUp;
readonly ChevronDown = ChevronDown;
form: FormGroup;
loreId = '';
@@ -75,7 +77,10 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
if (!name) return;
// Unicite par nom (on ignore le type pour eviter des collisions d'affichage).
if (this.fields.some(f => f.name === name)) return;
this.fields = [...this.fields, { name, type: this.newFieldType }];
const newField: TemplateField = this.newFieldType === 'IMAGE'
? { name, type: 'IMAGE', layout: 'GALLERY' }
: { name, type: 'TEXT' };
this.fields = [...this.fields, newField];
this.newFieldName = '';
// Le type reste sur la derniere valeur choisie : pratique pour enchainer
// plusieurs champs du meme type.
@@ -85,12 +90,33 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
this.fields = this.fields.filter((_, i) => i !== index);
}
/** Deplace un champ d'un cran vers le haut ou le bas. No-op aux bords. */
moveField(index: number, direction: -1 | 1): void {
const target = index + direction;
if (target < 0 || target >= this.fields.length) return;
const next = [...this.fields];
[next[index], next[target]] = [next[target], next[index]];
this.fields = next;
}
/** Bascule le type d'un champ existant (TEXT <-> IMAGE). */
toggleFieldType(index: number): void {
const field = this.fields[index];
if (!field) return;
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT';
this.fields = this.fields.map((f, i) => i === index ? { ...f, type: nextType } : f);
this.fields = this.fields.map((f, i) => {
if (i !== index) return f;
return nextType === 'IMAGE'
? { name: f.name, type: 'IMAGE', layout: f.layout ?? 'GALLERY' }
: { name: f.name, type: 'TEXT' };
});
}
/** Met a jour le layout d'un champ IMAGE. */
setFieldLayout(index: number, layout: ImageLayout): void {
this.fields = this.fields.map((f, i) =>
i === index && f.type === 'IMAGE' ? { ...f, layout } : f
);
}
submit(): void {

View File

@@ -43,7 +43,21 @@
<label class="section-label">Champs du template</label>
<ul class="fields-list">
<li class="field-row" *ngFor="let f of fields; let i = index">
<li class="field-row" *ngFor="let f of fields; let i = index; let first = first; let last = last">
<div class="reorder-stack">
<button type="button" class="btn-icon-ghost btn-reorder"
(click)="moveField(i, -1)"
[disabled]="first"
aria-label="Monter d'un cran" title="Monter">
<lucide-icon [img]="ChevronUp" [size]="12"></lucide-icon>
</button>
<button type="button" class="btn-icon-ghost btn-reorder"
(click)="moveField(i, 1)"
[disabled]="last"
aria-label="Descendre d'un cran" title="Descendre">
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
</button>
</div>
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'">
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
{{ f.name }}
@@ -54,6 +68,17 @@
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
</button>
<select *ngIf="f.type === 'IMAGE'"
class="layout-select"
[ngModel]="f.layout ?? 'GALLERY'"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="setFieldLayout(i, $event)"
title="Mise en page des images">
<option value="GALLERY">Grille</option>
<option value="HERO">Heros</option>
<option value="MASONRY">Mosaique</option>
<option value="CAROUSEL">Carrousel</option>
</select>
<button type="button" class="btn-icon-ghost" (click)="removeField(i)" aria-label="Supprimer">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>

View File

@@ -125,7 +125,8 @@
&:hover { color: #a5b4fc; background: #1f1b3a; }
}
.type-select {
.type-select,
.layout-select {
background: #1a1a2e;
border: 1px solid #2a2a3d;
color: white;
@@ -138,6 +139,12 @@
&:focus { outline: none; border-color: #6c63ff; }
}
.layout-select {
height: 28px;
font-size: 0.72rem;
padding: 0 0.45rem;
}
input {
flex: 1;
background: #1a1a2e;
@@ -167,6 +174,35 @@
&:focus { border: none; }
}
}
.reorder-stack {
display: flex;
flex-direction: column;
gap: 2px;
.btn-reorder {
width: 22px;
height: 16px;
background: transparent;
color: #6b7280;
border: 1px solid #2a2a3d;
border-radius: 3px;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.12s, color 0.12s, border-color 0.12s;
&:hover:not(:disabled) {
background: #2a2a3d;
color: white;
border-color: #6c63ff;
}
&:disabled { opacity: 0.3; cursor: not-allowed; }
}
}
}
.btn-icon-ghost {

View File

@@ -3,14 +3,14 @@ import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, Plus, X, Trash2, Type, Image as ImageIcon } from 'lucide-angular';
import { LucideAngularModule, Plus, X, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { LoreNode } from '../../services/lore.model';
import { FieldType, Template, TemplateField } from '../../services/template.model';
import { FieldType, ImageLayout, Template, TemplateField } from '../../services/template.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
/**
@@ -30,6 +30,8 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2;
readonly Type = Type;
readonly ImageIcon = ImageIcon;
readonly ChevronUp = ChevronUp;
readonly ChevronDown = ChevronDown;
form: FormGroup;
loreId = '';
@@ -75,10 +77,12 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
this.template = template;
// Copie defensive + normalisation du type (defaut TEXT si inconnu/manquant,
// utile pour les templates legacy cote frontend meme si le backend le fait aussi).
this.fields = (template.fields ?? []).map(f => ({
name: f.name,
type: f.type === 'IMAGE' ? 'IMAGE' : 'TEXT'
}));
this.fields = (template.fields ?? []).map(f => {
const type: FieldType = f.type === 'IMAGE' ? 'IMAGE' : 'TEXT';
return type === 'IMAGE'
? { name: f.name, type, layout: f.layout ?? 'GALLERY' }
: { name: f.name, type };
});
this.form.patchValue({
name: template.name,
description: template.description,
@@ -91,7 +95,10 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
const name = this.newFieldName.trim();
if (!name) return;
if (this.fields.some(f => f.name === name)) return;
this.fields = [...this.fields, { name, type: this.newFieldType }];
const newField: TemplateField = this.newFieldType === 'IMAGE'
? { name, type: 'IMAGE', layout: 'GALLERY' }
: { name, type: 'TEXT' };
this.fields = [...this.fields, newField];
this.newFieldName = '';
}
@@ -99,12 +106,33 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
this.fields = this.fields.filter((_, i) => i !== index);
}
/** Deplace un champ d'un cran vers le haut ou le bas. No-op aux bords. */
moveField(index: number, direction: -1 | 1): void {
const target = index + direction;
if (target < 0 || target >= this.fields.length) return;
const next = [...this.fields];
[next[index], next[target]] = [next[target], next[index]];
this.fields = next;
}
/** Bascule le type d'un champ (TEXT <-> IMAGE). */
toggleFieldType(index: number): void {
const field = this.fields[index];
if (!field) return;
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT';
this.fields = this.fields.map((f, i) => i === index ? { ...f, type: nextType } : f);
this.fields = this.fields.map((f, i) => {
if (i !== index) return f;
return nextType === 'IMAGE'
? { name: f.name, type: 'IMAGE', layout: f.layout ?? 'GALLERY' }
: { name: f.name, type: 'TEXT' };
});
}
/** Met a jour le layout d'un champ IMAGE. */
setFieldLayout(index: number, layout: ImageLayout): void {
this.fields = this.fields.map((f, i) =>
i === index && f.type === 'IMAGE' ? { ...f, layout } : f
);
}
save(): void {

View File

@@ -36,8 +36,11 @@ export interface Arc {
/** IDs des pages du Lore liées à cet arc (weak cross-context refs). */
relatedPageIds?: string[];
/** IDs des images (Shared Kernel) illustrant cet arc. */
/** IDs des images (Shared Kernel) illustrant cet arc (ambiance). */
illustrationImageIds?: string[];
/** IDs des images utilisees comme cartes / plans (outil de table). */
mapImageIds?: string[];
}
// Payload pour la création d'un Arc (pas d'id)
@@ -55,6 +58,7 @@ export interface ArcCreate {
relatedPageIds?: string[];
illustrationImageIds?: string[];
mapImageIds?: string[];
}
export interface Chapter {
@@ -71,6 +75,7 @@ export interface Chapter {
relatedPageIds?: string[];
illustrationImageIds?: string[];
mapImageIds?: string[];
}
export interface ChapterCreate {
@@ -85,6 +90,7 @@ export interface ChapterCreate {
relatedPageIds?: string[];
illustrationImageIds?: string[];
mapImageIds?: string[];
}
/**
@@ -116,6 +122,7 @@ export interface Scene {
relatedPageIds?: string[];
illustrationImageIds?: string[];
mapImageIds?: string[];
/** Sorties narratives (graphe intra-chapitre). */
branches?: SceneBranch[];
@@ -138,5 +145,6 @@ export interface SceneCreate {
relatedPageIds?: string[];
illustrationImageIds?: string[];
mapImageIds?: string[];
branches?: SceneBranch[];
}

View File

@@ -7,6 +7,16 @@
*/
export type FieldType = 'TEXT' | 'IMAGE';
/**
* Variante de rendu pour un champ IMAGE. Miroir de
* com.loremind.domain.lorecontext.ImageLayout. Ignore pour TEXT.
* - 'GALLERY' : grille de vignettes (defaut)
* - 'HERO' : premiere image en banniere, suivantes en petit
* - 'MASONRY' : mosaique hauteurs variables
* - 'CAROUSEL' : defilement horizontal
*/
export type ImageLayout = 'GALLERY' | 'HERO' | 'MASONRY' | 'CAROUSEL' | 'EDITORIAL' | 'MAPS';
/**
* Champ d'un Template : nom + type discriminant.
* Miroir de TemplateFieldDTO (backend).
@@ -14,6 +24,8 @@ export type FieldType = 'TEXT' | 'IMAGE';
export interface TemplateField {
name: string;
type: FieldType;
/** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */
layout?: ImageLayout | null;
}
export interface Template {

View File

@@ -1,14 +1,31 @@
<!-- Grille de vignettes + uploader si editable. -->
<div class="gallery"
*ngIf="imageIds.length > 0 || editable; else empty">
<!-- Container avec classe dynamique selon le layout choisi. -->
<div [ngSwitch]="effectiveLayout" class="gallery-root">
<div class="gallery-tile"
*ngFor="let id of imageIds"
<!-- =================== HERO =================== -->
<ng-container *ngSwitchCase="'HERO'">
<div class="hero" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="hero-main"
*ngIf="heroId"
(click)="openLightbox(heroId)"
role="button"
tabindex="0">
<img [src]="urlFor(heroId)" [alt]="'Illustration principale'" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(heroId, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="hero-rest" *ngIf="restIds.length > 0 || editable">
<div class="gallery-tile hero-thumb"
*ngFor="let id of restIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
@@ -17,13 +34,150 @@
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<app-image-uploader *ngIf="editable" [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
<!-- Bouton + (uploader compact), uniquement en mode edition -->
<app-image-uploader
<!-- Si pas de hero mais editable, on montre au moins l'uploader. -->
<div class="hero-rest" *ngIf="!heroId && editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
</ng-container>
<!-- =================== MASONRY =================== -->
<ng-container *ngSwitchCase="'MASONRY'">
<div class="masonry" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="masonry-item"
*ngFor="let id of imageIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
[compact]="true"
(uploaded)="onUploaded($event)">
</app-image-uploader>
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="masonry-item masonry-uploader" *ngIf="editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
</ng-container>
<!-- =================== CAROUSEL =================== -->
<ng-container *ngSwitchCase="'CAROUSEL'">
<div class="carousel" *ngIf="imageIds.length > 0 || editable; else empty">
<button type="button"
class="carousel-nav carousel-prev"
*ngIf="imageIds.length > 1"
(click)="scrollCarousel(-1)"
aria-label="Precedent">
<lucide-icon [img]="ChevronLeft" [size]="20"></lucide-icon>
</button>
<div class="carousel-track" #carouselTrack>
<div class="carousel-slide"
*ngFor="let id of imageIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="carousel-slide carousel-uploader" *ngIf="editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
<button type="button"
class="carousel-nav carousel-next"
*ngIf="imageIds.length > 1"
(click)="scrollCarousel(1)"
aria-label="Suivant">
<lucide-icon [img]="ChevronRight" [size]="20"></lucide-icon>
</button>
</div>
</ng-container>
<!-- =================== EDITORIAL =================== -->
<!-- Rendu adaptatif facon magazine : 1 image → hero, 2 → diptyque, 3 → feature + 2 satellites, 4+ → feature + 3 satellites. -->
<ng-container *ngSwitchCase="'EDITORIAL'">
<div class="editorial" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="editorial-item"
*ngFor="let id of imageIds; let i = index"
[class.editorial-feature]="i === 0"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="editorial-item editorial-uploader" *ngIf="editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
</ng-container>
<!-- =================== MAPS =================== -->
<!-- Cartes / plans : grandes vignettes, ratio natif preserve (pas de crop). -->
<ng-container *ngSwitchCase="'MAPS'">
<div class="maps" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="map-tile"
*ngFor="let id of imageIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Carte ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette carte">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="map-tile map-uploader" *ngIf="editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
</ng-container>
<!-- =================== GALLERY (default) =================== -->
<ng-container *ngSwitchDefault>
<div class="gallery" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="gallery-tile"
*ngFor="let id of imageIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<app-image-uploader *ngIf="editable" [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</ng-container>
</div>
<!-- Etat vide (lecture uniquement). -->

View File

@@ -1,33 +1,36 @@
.gallery {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
align-items: flex-start;
}
// =============== Common tile / remove-button ===============
// Partage par tous les layouts : vignette, survol, bouton X.
.gallery-tile {
.gallery-tile,
.masonry-item,
.hero-thumb,
.carousel-slide,
.hero-main,
.map-tile {
position: relative;
width: 120px;
height: 120px;
border-radius: 6px;
border-radius: 8px;
overflow: hidden;
background: #1a1a2e;
border: 1px solid #2a2a3d;
cursor: zoom-in;
transition: border-color 0.15s, transform 0.15s;
transition: border-color 0.15s, transform 0.15s, box-shadow 0.2s;
&:hover {
border-color: #6c63ff;
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(108, 99, 255, 0.18);
.gallery-remove { opacity: 1; }
img { transform: scale(1.04); }
}
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.3s ease;
}
}
@@ -47,6 +50,7 @@
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s;
z-index: 2;
&:hover { background: #7f1d1d; color: white; }
}
@@ -60,7 +64,352 @@
font-style: italic;
}
// Lightbox plein ecran
// =============== Layout: GALLERY (planche de contact) ===============
// Grille stricte de carres identiques, effet "contact sheet" photo.
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 0.35rem;
padding: 0.35rem;
background: #12121f;
border-radius: 6px;
max-width: 720px; // contient la grille pour ne pas etaler sur tout l'ecran
}
.gallery-tile {
width: auto;
aspect-ratio: 1 / 1;
border-radius: 2px; // carres vifs, presque sans radius
}
// =============== Layout: HERO ===============
.hero {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.hero-main {
width: 100%;
aspect-ratio: 21 / 9;
max-height: 360px;
cursor: zoom-in;
img { object-position: center; }
}
.hero-rest {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.hero-thumb {
width: 90px;
height: 90px;
}
// =============== Layout: MASONRY (Pinterest) ===============
// Colonnes larges, hauteurs naturelles preservees. Effet tres visible si les
// images n'ont pas toutes le meme ratio. Le border-radius genereux et les
// ombres accentuent le cote "tableau d'inspiration".
.masonry {
column-count: 3;
column-gap: 1.2rem;
padding: 0.5rem 0;
@media (max-width: 900px) { column-count: 2; }
@media (max-width: 500px) { column-count: 1; }
}
.masonry-item {
display: inline-block;
width: 100%;
margin-bottom: 1.2rem;
break-inside: avoid;
border-radius: 14px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
// Override de la transition par defaut pour un feel plus doux.
transition: transform 0.25s ease, box-shadow 0.25s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(108, 99, 255, 0.35);
}
img {
height: auto; // ratio natif preserve → hauteur variable entre les tuiles
border-radius: 14px;
}
}
.masonry-uploader {
aspect-ratio: 3 / 4; // slot vertical, bien different d'une tuile simple
border-style: dashed;
border-width: 2px;
cursor: default;
box-shadow: none;
&:hover { transform: none; box-shadow: none; }
}
// =============== Layout: CAROUSEL (cinema) ===============
// Bande horizontale facon affiche de film : grandes slides 16/9, ombres
// marquees, fade sur les bords pour suggerer le defilement infini.
.carousel {
position: relative;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0;
// Fade gauche/droite pour signaler clairement "ca defile".
&::before,
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 48px;
pointer-events: none;
z-index: 3;
}
&::before { left: 48px; background: linear-gradient(to right, #0f0f1e 0%, transparent 100%); }
&::after { right: 48px; background: linear-gradient(to left, #0f0f1e 0%, transparent 100%); }
}
.carousel-track {
display: flex;
gap: 1rem;
overflow-x: auto;
scroll-behavior: smooth;
scroll-snap-type: x mandatory;
padding: 0.5rem 0.25rem;
flex: 1;
min-width: 0;
&::-webkit-scrollbar { height: 6px; }
&::-webkit-scrollbar-track { background: transparent; }
&::-webkit-scrollbar-thumb {
background: #2a2a3d;
border-radius: 3px;
}
}
.carousel-slide {
flex: 0 0 auto;
width: 360px;
aspect-ratio: 16 / 9;
height: auto;
scroll-snap-align: start;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
&:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 16px 40px rgba(108, 99, 255, 0.4);
}
}
.carousel-uploader {
width: 220px;
aspect-ratio: 16 / 9;
border-style: dashed;
border-width: 2px;
cursor: default;
box-shadow: none;
&:hover { transform: none; box-shadow: none; }
}
.carousel-nav {
flex: 0 0 auto;
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #2a2a3d;
border-radius: 50%;
background: rgba(26, 26, 46, 0.9);
color: #9ca3af;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
&:hover {
color: white;
border-color: #6c63ff;
background: #1f1b3a;
}
}
// =============== Layout: EDITORIAL (scrapbook polaroid) ===============
// Rendu carnet de campagne : vignettes facon polaroid, legerement inclinees,
// avec bande de papier collant (::before) et ombre portee. Au survol, la photo
// se redresse et se souleve. Pas de grille rigide : flex-wrap laisse respirer.
.editorial {
display: flex;
flex-wrap: wrap;
gap: 2rem 1.25rem;
padding: 1.25rem 0.5rem 0.5rem;
align-items: flex-start;
justify-content: flex-start;
// Fond kraft/parchemin tres discret pour suggerer le carnet.
background:
radial-gradient(ellipse at 20% 20%, rgba(180, 150, 100, 0.05), transparent 60%),
radial-gradient(ellipse at 80% 70%, rgba(160, 120, 80, 0.04), transparent 60%);
border-radius: 8px;
}
.editorial-item {
position: relative;
flex: 0 0 220px;
max-width: 100%;
background: #f5efe0; // papier blanc casse
padding: 10px 10px 34px 10px; // bas = bande blanche facon polaroid
border-radius: 2px;
cursor: zoom-in;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.35),
0 14px 28px rgba(0, 0, 0, 0.45);
transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.3, 1),
box-shadow 0.3s ease;
// Rotations pseudo-aleatoires pour casser l'effet grille.
transform: rotate(-2deg);
&:nth-child(2n) { transform: rotate(1.8deg); }
&:nth-child(3n) { transform: rotate(-1.2deg); }
&:nth-child(4n) { transform: rotate(2.5deg); }
&:nth-child(5n) { transform: rotate(-2.8deg); }
&:nth-child(7n) { transform: rotate(0.9deg); }
// Ruban adhesif en haut de la photo.
&::before {
content: '';
position: absolute;
top: -9px;
left: 50%;
width: 68px;
height: 18px;
transform: translateX(-50%) rotate(-4deg);
background: rgba(255, 238, 200, 0.55);
border-left: 1px dashed rgba(0, 0, 0, 0.08);
border-right: 1px dashed rgba(0, 0, 0, 0.08);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
pointer-events: none;
}
&:nth-child(2n)::before { transform: translateX(-50%) rotate(3deg); }
&:nth-child(3n)::before { transform: translateX(-50%) rotate(-7deg); left: 58%; }
&:nth-child(4n)::before { transform: translateX(-50%) rotate(5deg); left: 42%; }
&:hover {
transform: rotate(0deg) scale(1.05) translateY(-4px);
z-index: 10;
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.5),
0 24px 48px rgba(0, 0, 0, 0.6);
.gallery-remove { opacity: 1; }
}
img {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
background: #1a1a2e;
border-radius: 0;
}
}
// La premiere image (feature) est plus grande et en ratio 4/3 pour jouer le role d'affiche.
.editorial-feature {
flex: 0 0 420px;
img { aspect-ratio: 4 / 3; }
}
// Bouton X : sur polaroid blanc, on renforce le contraste.
.editorial-item .gallery-remove {
top: 14px;
right: 14px;
background: rgba(17, 17, 30, 0.92);
color: #fecaca;
&:hover { background: #7f1d1d; color: white; }
}
// Uploader : meme cadre polaroid mais en "coller une photo ici" dashed.
.editorial-uploader {
background: rgba(245, 239, 224, 0.06);
border: 2px dashed rgba(108, 99, 255, 0.7);
padding: 0;
cursor: default;
&::before { display: none; } // pas de scotch sur le slot vide
&:hover {
transform: rotate(0deg);
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.35),
0 14px 28px rgba(0, 0, 0, 0.45);
}
// L'uploader interne doit remplir le slot.
app-image-uploader { display: block; width: 100%; height: 100%; min-height: 180px; }
}
// Responsive : on reduit la taille et on supprime les rotations sur mobile.
@media (max-width: 780px) {
.editorial-item { flex: 0 0 calc(50% - 0.75rem); }
.editorial-feature { flex: 0 0 calc(100% - 0.5rem); }
}
@media (max-width: 480px) {
.editorial { gap: 1.25rem 0.75rem; }
.editorial-item,
.editorial-feature {
flex: 0 0 100%;
transform: rotate(0deg) !important;
&::before { display: none; }
}
}
// =============== Layout: MAPS ===============
// Plans et cartes : on ne CROP pas (une carte croppee ne sert a rien).
// Grandes vignettes, ratio natif preserve via object-fit: contain.
.maps {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.map-tile {
aspect-ratio: 4 / 3;
background:
linear-gradient(45deg, #15152440 25%, transparent 25%),
linear-gradient(-45deg, #15152440 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #15152440 75%),
linear-gradient(-45deg, transparent 75%, #15152440 75%),
#1a1a2e;
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
img {
object-fit: contain; // Preserve le ratio natif, ajoute un padding visuel via le fond.
padding: 4px;
}
}
.map-uploader {
border-style: dashed;
cursor: default;
background: #1a1a2e;
&:hover { transform: none; box-shadow: none; }
}
// =============== Lightbox (inchange) ===============
.lightbox-backdrop {
position: fixed;
inset: 0;

View File

@@ -1,8 +1,9 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, X, Image as ImageIcon } from 'lucide-angular';
import { LucideAngularModule, X, Image as ImageIcon, ChevronLeft, ChevronRight } from 'lucide-angular';
import { ImageService } from '../../services/image.service';
import { Image } from '../../services/image.model';
import { ImageLayout } from '../../services/template.model';
import { ImageUploaderComponent } from '../image-uploader/image-uploader.component';
/**
@@ -34,6 +35,8 @@ import { ImageUploaderComponent } from '../image-uploader/image-uploader.compone
export class ImageGalleryComponent {
readonly X = X;
readonly ImageIcon = ImageIcon;
readonly ChevronLeft = ChevronLeft;
readonly ChevronRight = ChevronRight;
/** IDs d'images a afficher. */
@Input() imageIds: string[] = [];
@@ -41,14 +44,45 @@ export class ImageGalleryComponent {
/** Mode edition : afficher le bouton d'ajout + les boutons de suppression. */
@Input() editable = false;
/**
* Variante de mise en page. Null/undefined = GALLERY (rendu historique).
* HERO : premiere image en banniere pleine largeur, suivantes en petit dessous.
* MASONRY : mosaique a hauteurs variables.
* CAROUSEL : defilement horizontal avec fleches.
*/
@Input() layout: ImageLayout | null | undefined = 'GALLERY';
/** Emet la nouvelle liste quand l'utilisateur ajoute/retire une image. */
@Output() imageIdsChange = new EventEmitter<string[]>();
/** ID de l'image actuellement ouverte en lightbox (null = ferme). */
lightboxId: string | null = null;
@ViewChild('carouselTrack') carouselTrack?: ElementRef<HTMLDivElement>;
constructor(private imageService: ImageService) {}
/** Layout effectif (null → GALLERY). */
get effectiveLayout(): ImageLayout {
return this.layout ?? 'GALLERY';
}
/** Premiere image (pour le layout HERO). */
get heroId(): string | null {
return this.imageIds[0] ?? null;
}
/** Images restantes apres la hero (pour le layout HERO). */
get restIds(): string[] {
return this.imageIds.slice(1);
}
scrollCarousel(direction: -1 | 1): void {
const el = this.carouselTrack?.nativeElement;
if (!el) return;
el.scrollBy({ left: direction * Math.max(240, el.clientWidth * 0.8), behavior: 'smooth' });
}
/** URL absolue du binaire d'une image. */
urlFor(id: string): string {
return this.imageService.contentUrl(id);

View File

@@ -60,7 +60,7 @@
</div>
<div class="sidebar-footer">
<span class="version">Version 0.2.0</span>
<span class="version">Version 0.3.0</span>
</div>
</aside>