Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88278bd1dd | |||
| d24d6459a0 | |||
| 4b866e5212 | |||
| 6c6bd20f0d | |||
| 2764228abf | |||
| f95d69c915 | |||
| 70351e9d9a | |||
| ff4905126d | |||
| 0e5b5a7de4 | |||
| c8c032336b | |||
| dda27e55fc | |||
| 83ac67471e | |||
| e3c8232e38 | |||
| a4df9fc759 | |||
| f1989c1d77 | |||
| 8efdf5d0e0 | |||
| 96bc5de942 | |||
| 84ccdd53ad |
95
.gitea/workflows/e2e.yml
Normal file
95
.gitea/workflows/e2e.yml
Normal file
@@ -0,0 +1,95 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Create .env for stack
|
||||
run: |
|
||||
cat > .env <<'EOF'
|
||||
POSTGRES_PASSWORD=ci-postgres-pass
|
||||
BRAIN_INTERNAL_SECRET=ci-brain-secret
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=ci-admin-pass
|
||||
WEB_PORT=8081
|
||||
LLM_PROVIDER=ollama
|
||||
EOF
|
||||
|
||||
- name: Build & start stack
|
||||
run: |
|
||||
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build
|
||||
|
||||
- name: Attach runner to compose network
|
||||
run: |
|
||||
NET=$(docker network ls --format '{{.Name}}' | grep -E '(^|_)loremind(_|$)' | grep -i default | head -1)
|
||||
if [ -z "$NET" ]; then
|
||||
echo "Compose network not found" >&2
|
||||
docker network ls
|
||||
exit 1
|
||||
fi
|
||||
echo "Connecting $(hostname) to network $NET"
|
||||
docker network connect "$NET" "$(hostname)"
|
||||
|
||||
- name: Wait for web to be ready
|
||||
run: |
|
||||
timeout 180 bash -c 'until curl -sf http://web/ > /dev/null; do echo "waiting..."; sleep 3; done'
|
||||
|
||||
- name: Install web deps
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Work around runner clock skew for apt
|
||||
run: |
|
||||
sudo tee /etc/apt/apt.conf.d/99no-check-valid-until >/dev/null <<'EOF'
|
||||
Acquire::Check-Valid-Until "false";
|
||||
Acquire::Check-Date "false";
|
||||
EOF
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: web
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright tests
|
||||
working-directory: web
|
||||
env:
|
||||
E2E_BASE_URL: http://web
|
||||
CI: 'true'
|
||||
run: npm run e2e
|
||||
|
||||
- name: Dump container logs on failure
|
||||
if: failure()
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: web/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
- name: Stop stack
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml down -v
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -53,6 +53,12 @@ yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
coverage/
|
||||
|
||||
# Playwright (E2E)
|
||||
web/test-results/
|
||||
web/playwright-report/
|
||||
web/blob-report/
|
||||
web/playwright/.cache/
|
||||
|
||||
# ============================================================================
|
||||
# IDE / Editeurs
|
||||
# ============================================================================
|
||||
|
||||
@@ -40,7 +40,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.5.0",
|
||||
version="0.6.2",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>0.5.0</version>
|
||||
<version>0.6.2</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -17,11 +21,20 @@ import java.util.Optional;
|
||||
public class ArcService {
|
||||
|
||||
private final ArcRepository arcRepository;
|
||||
private final ChapterRepository chapterRepository;
|
||||
private final SceneRepository sceneRepository;
|
||||
|
||||
public ArcService(ArcRepository arcRepository) {
|
||||
public ArcService(ArcRepository arcRepository,
|
||||
ChapterRepository chapterRepository,
|
||||
SceneRepository sceneRepository) {
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.sceneRepository = sceneRepository;
|
||||
}
|
||||
|
||||
/** Compte des entités qui seront supprimées en cascade avec l'arc. */
|
||||
public record DeletionImpact(int chapters, int scenes) {}
|
||||
|
||||
public Arc createArc(String name, String description, String campaignId, int order) {
|
||||
Arc arc = Arc.builder()
|
||||
.name(name)
|
||||
@@ -59,7 +72,31 @@ public class ArcService {
|
||||
return arcRepository.save(arc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule l'impact d'une suppression en cascade : chapitres + scènes
|
||||
* qui disparaîtront avec l'arc.
|
||||
*/
|
||||
public DeletionImpact getDeletionImpact(String id) {
|
||||
List<Chapter> chapters = chapterRepository.findByArcId(id);
|
||||
int sceneTotal = 0;
|
||||
for (Chapter chapter : chapters) {
|
||||
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
|
||||
}
|
||||
return new DeletionImpact(chapters.size(), sceneTotal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime l'arc et toutes ses entités dépendantes (chapitres → scènes).
|
||||
* Transactionnel : atomique.
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteArc(String id) {
|
||||
for (Chapter chapter : chapterRepository.findByArcId(id)) {
|
||||
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
|
||||
sceneRepository.deleteById(scene.getId());
|
||||
}
|
||||
chapterRepository.deleteById(chapter.getId());
|
||||
}
|
||||
arcRepository.deleteById(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
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.CharacterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -16,9 +23,22 @@ import java.util.Optional;
|
||||
public class CampaignService {
|
||||
|
||||
private final CampaignRepository campaignRepository;
|
||||
private final ArcRepository arcRepository;
|
||||
private final ChapterRepository chapterRepository;
|
||||
private final SceneRepository sceneRepository;
|
||||
private final CharacterRepository characterRepository;
|
||||
|
||||
public CampaignService(CampaignRepository campaignRepository) {
|
||||
public CampaignService(
|
||||
CampaignRepository campaignRepository,
|
||||
ArcRepository arcRepository,
|
||||
ChapterRepository chapterRepository,
|
||||
SceneRepository sceneRepository,
|
||||
CharacterRepository characterRepository) {
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.sceneRepository = sceneRepository;
|
||||
this.characterRepository = characterRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,6 +50,12 @@ public class CampaignService {
|
||||
*/
|
||||
public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
|
||||
|
||||
/**
|
||||
* Compte des entités qui seront supprimées en cascade si la campagne est effacée.
|
||||
* Utilisé par l'UI pour afficher un récapitulatif dans le dialogue de confirmation.
|
||||
*/
|
||||
public record DeletionImpact(int arcs, int chapters, int scenes, int characters) {}
|
||||
|
||||
public Campaign createCampaign(CampaignData data) {
|
||||
Campaign campaign = Campaign.builder()
|
||||
.name(data.name())
|
||||
@@ -71,7 +97,48 @@ public class CampaignService {
|
||||
return (id == null || id.isBlank()) ? null : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule l'impact d'une suppression en cascade : nombre d'arcs, chapitres,
|
||||
* scènes et personnages qui disparaîtront avec la campagne. Utilisé par l'UI
|
||||
* pour afficher "X arcs, Y chapitres, Z scènes seront supprimés".
|
||||
*/
|
||||
public DeletionImpact getDeletionImpact(String id) {
|
||||
List<Arc> arcs = arcRepository.findByCampaignId(id);
|
||||
int chapterTotal = 0;
|
||||
int sceneTotal = 0;
|
||||
for (Arc arc : arcs) {
|
||||
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
|
||||
chapterTotal += chapters.size();
|
||||
for (Chapter chapter : chapters) {
|
||||
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
|
||||
}
|
||||
}
|
||||
int characterTotal = characterRepository.findByCampaignId(id).size();
|
||||
return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, characterTotal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime la campagne et toutes ses entités dépendantes (arcs → chapitres →
|
||||
* scènes, plus les personnages). L'opération est transactionnelle : soit
|
||||
* tout disparaît, soit rien ne change. Les FKs applicatives n'ayant pas
|
||||
* de contrainte CASCADE au niveau DB, on orchestre la cascade ici.
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteCampaign(String id) {
|
||||
List<Arc> arcs = arcRepository.findByCampaignId(id);
|
||||
for (Arc arc : arcs) {
|
||||
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
|
||||
for (Chapter chapter : chapters) {
|
||||
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
|
||||
sceneRepository.deleteById(scene.getId());
|
||||
}
|
||||
chapterRepository.deleteById(chapter.getId());
|
||||
}
|
||||
arcRepository.deleteById(arc.getId());
|
||||
}
|
||||
for (var character : characterRepository.findByCampaignId(id)) {
|
||||
characterRepository.deleteById(character.getId());
|
||||
}
|
||||
campaignRepository.deleteById(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -17,11 +19,16 @@ import java.util.Optional;
|
||||
public class ChapterService {
|
||||
|
||||
private final ChapterRepository chapterRepository;
|
||||
private final SceneRepository sceneRepository;
|
||||
|
||||
public ChapterService(ChapterRepository chapterRepository) {
|
||||
public ChapterService(ChapterRepository chapterRepository, SceneRepository sceneRepository) {
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.sceneRepository = sceneRepository;
|
||||
}
|
||||
|
||||
/** Compte des scènes qui seront supprimées en cascade avec le chapitre. */
|
||||
public record DeletionImpact(int scenes) {}
|
||||
|
||||
public Chapter createChapter(String name, String description, String arcId, int order) {
|
||||
Chapter chapter = Chapter.builder()
|
||||
.name(name)
|
||||
@@ -58,7 +65,17 @@ public class ChapterService {
|
||||
return chapterRepository.save(chapter);
|
||||
}
|
||||
|
||||
/** Compte des scènes qui tomberont avec le chapitre. */
|
||||
public DeletionImpact getDeletionImpact(String id) {
|
||||
return new DeletionImpact(sceneRepository.findByChapterId(id).size());
|
||||
}
|
||||
|
||||
/** Supprime le chapitre et toutes ses scènes. Transactionnel : atomique. */
|
||||
@Transactional
|
||||
public void deleteChapter(String id) {
|
||||
for (var scene : sceneRepository.findByChapterId(id)) {
|
||||
sceneRepository.deleteById(scene.getId());
|
||||
}
|
||||
chapterRepository.deleteById(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.loremind.application.lorecontext;
|
||||
|
||||
import com.loremind.domain.lorecontext.LoreNode;
|
||||
import com.loremind.domain.lorecontext.Page;
|
||||
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -16,11 +20,20 @@ import java.util.Optional;
|
||||
public class LoreNodeService {
|
||||
|
||||
private final LoreNodeRepository loreNodeRepository;
|
||||
private final PageRepository pageRepository;
|
||||
|
||||
public LoreNodeService(LoreNodeRepository loreNodeRepository) {
|
||||
public LoreNodeService(LoreNodeRepository loreNodeRepository, PageRepository pageRepository) {
|
||||
this.loreNodeRepository = loreNodeRepository;
|
||||
this.pageRepository = pageRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte des entités qui seront supprimées en cascade si le dossier est effacé :
|
||||
* le dossier lui-même n'est pas compté, seuls les descendants (sous-dossiers
|
||||
* récursifs + pages de l'ensemble du sous-arbre).
|
||||
*/
|
||||
public record DeletionImpact(int folders, int pages) {}
|
||||
|
||||
/**
|
||||
* Crée un LoreNode (dossier) à partir d'un "objet changes" porteur des valeurs
|
||||
* souhaitées (pattern Parameter Object) : évite les signatures qui gonflent
|
||||
@@ -68,7 +81,64 @@ public class LoreNodeService {
|
||||
return loreNodeRepository.save(existing);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule l'impact d'une suppression en cascade : nombre de sous-dossiers
|
||||
* (récursif, sans compter la racine) et de pages dans l'ensemble du sous-arbre.
|
||||
*/
|
||||
public DeletionImpact getDeletionImpact(String id) {
|
||||
List<LoreNode> descendants = collectDescendants(id);
|
||||
int pageTotal = pageRepository.findByNodeId(id).size();
|
||||
for (LoreNode descendant : descendants) {
|
||||
pageTotal += pageRepository.findByNodeId(descendant.getId()).size();
|
||||
}
|
||||
return new DeletionImpact(descendants.size(), pageTotal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime le dossier et tout son sous-arbre (sous-dossiers récursifs + pages).
|
||||
* Suppression en profondeur d'abord (feuilles → racine) pour limiter les
|
||||
* références orphelines en cours de transaction. Les FKs applicatives n'ayant
|
||||
* pas de CASCADE en DB, on orchestre la descente ici.
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteLoreNode(String id) {
|
||||
List<LoreNode> descendants = collectDescendants(id);
|
||||
// Descendants retournés en ordre BFS (haut → bas) : on inverse pour
|
||||
// supprimer les feuilles en premier, puis on finit par la racine.
|
||||
for (int i = descendants.size() - 1; i >= 0; i--) {
|
||||
String descendantId = descendants.get(i).getId();
|
||||
deletePagesOfNode(descendantId);
|
||||
loreNodeRepository.deleteById(descendantId);
|
||||
}
|
||||
deletePagesOfNode(id);
|
||||
loreNodeRepository.deleteById(id);
|
||||
}
|
||||
|
||||
private void deletePagesOfNode(String nodeId) {
|
||||
for (Page page : pageRepository.findByNodeId(nodeId)) {
|
||||
pageRepository.deleteById(page.getId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne tous les descendants (hors racine) d'un dossier, en ordre BFS.
|
||||
* Parcours itératif pour éviter tout risque de débordement de pile sur
|
||||
* une arborescence profonde malicieuse.
|
||||
*/
|
||||
private List<LoreNode> collectDescendants(String rootId) {
|
||||
List<LoreNode> result = new ArrayList<>();
|
||||
List<String> frontier = new ArrayList<>();
|
||||
frontier.add(rootId);
|
||||
while (!frontier.isEmpty()) {
|
||||
List<String> nextFrontier = new ArrayList<>();
|
||||
for (String parentId : frontier) {
|
||||
for (LoreNode child : loreNodeRepository.findByParentId(parentId)) {
|
||||
result.add(child);
|
||||
nextFrontier.add(child.getId());
|
||||
}
|
||||
}
|
||||
frontier = nextFrontier;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
package com.loremind.application.lorecontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
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.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -26,15 +33,28 @@ public class LoreService {
|
||||
private final LoreRepository loreRepository;
|
||||
private final LoreNodeRepository loreNodeRepository;
|
||||
private final PageRepository pageRepository;
|
||||
private final TemplateRepository templateRepository;
|
||||
private final CampaignRepository campaignRepository;
|
||||
|
||||
public LoreService(LoreRepository loreRepository,
|
||||
LoreNodeRepository loreNodeRepository,
|
||||
PageRepository pageRepository) {
|
||||
PageRepository pageRepository,
|
||||
TemplateRepository templateRepository,
|
||||
CampaignRepository campaignRepository) {
|
||||
this.loreRepository = loreRepository;
|
||||
this.loreNodeRepository = loreNodeRepository;
|
||||
this.pageRepository = pageRepository;
|
||||
this.templateRepository = templateRepository;
|
||||
this.campaignRepository = campaignRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte des entités qui seront supprimées / détachées en cascade si le Lore
|
||||
* est effacé. `detachedCampaigns` : campagnes qui perdront leur référence à
|
||||
* ce Lore (leur loreId sera nullé) mais resteront présentes.
|
||||
*/
|
||||
public record DeletionImpact(int folders, int pages, int templates, int detachedCampaigns) {}
|
||||
|
||||
public Lore createLore(String name, String description) {
|
||||
Lore lore = Lore.builder()
|
||||
.name(name)
|
||||
@@ -83,7 +103,54 @@ public class LoreService {
|
||||
return loreRepository.save(lore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule l'impact d'une suppression de Lore en cascade : dossiers + pages
|
||||
* + templates supprimés, et campagnes qui seront détachées (loreId → null
|
||||
* sans être supprimées, car une campagne peut vivre sans univers).
|
||||
*/
|
||||
public DeletionImpact getDeletionImpact(String id) {
|
||||
int folders = (int) loreNodeRepository.countByLoreId(id);
|
||||
int pages = (int) pageRepository.countByLoreId(id);
|
||||
int templates = templateRepository.findByLoreId(id).size();
|
||||
int detached = countCampaignsReferencingLore(id);
|
||||
return new DeletionImpact(folders, pages, templates, detached);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime le Lore et toutes ses entités dépendantes (dossiers, pages, templates).
|
||||
* Les campagnes qui référençaient ce Lore sont conservées — leur loreId est
|
||||
* mis à null (une campagne peut légitimement exister sans univers associé).
|
||||
* Opération transactionnelle : atomique.
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteLore(String id) {
|
||||
// Pages d'abord : elles référencent nodeId ET loreId, on les supprime
|
||||
// globalement via loreId pour éviter d'en rater une rattachée à un
|
||||
// node orphelin (ne devrait pas arriver, mais ceinture+bretelles).
|
||||
for (Page page : pageRepository.findByLoreId(id)) {
|
||||
pageRepository.deleteById(page.getId());
|
||||
}
|
||||
for (LoreNode node : loreNodeRepository.findByLoreId(id)) {
|
||||
loreNodeRepository.deleteById(node.getId());
|
||||
}
|
||||
for (Template template : templateRepository.findByLoreId(id)) {
|
||||
templateRepository.deleteById(template.getId());
|
||||
}
|
||||
// Détache les campagnes : on garde la campagne, on nulle juste la référence.
|
||||
for (Campaign campaign : campaignRepository.findAll()) {
|
||||
if (id.equals(campaign.getLoreId())) {
|
||||
campaign.setLoreId(null);
|
||||
campaignRepository.save(campaign);
|
||||
}
|
||||
}
|
||||
loreRepository.deleteById(id);
|
||||
}
|
||||
|
||||
private int countCampaignsReferencingLore(String id) {
|
||||
int count = 0;
|
||||
for (Campaign campaign : campaignRepository.findAll()) {
|
||||
if (id.equals(campaign.getLoreId())) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,17 +40,11 @@ public class ArcController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<ArcDTO>> getAllArcs() {
|
||||
List<Arc> arcs = arcService.getAllArcs();
|
||||
List<ArcDTO> arcDTOs = arcs.stream()
|
||||
.map(arcMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(arcDTOs);
|
||||
}
|
||||
|
||||
@GetMapping("/campaign/{campaignId}")
|
||||
public ResponseEntity<List<ArcDTO>> getArcsByCampaignId(@PathVariable String campaignId) {
|
||||
List<Arc> arcs = arcService.getArcsByCampaignId(campaignId);
|
||||
public ResponseEntity<List<ArcDTO>> getAllArcs(
|
||||
@RequestParam(value = "campaignId", required = false) String campaignId) {
|
||||
List<Arc> arcs = (campaignId != null && !campaignId.isBlank())
|
||||
? arcService.getArcsByCampaignId(campaignId)
|
||||
: arcService.getAllArcs();
|
||||
List<ArcDTO> arcDTOs = arcs.stream()
|
||||
.map(arcMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
@@ -68,4 +62,12 @@ public class ArcController {
|
||||
arcService.deleteArc(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/deletion-impact")
|
||||
public ResponseEntity<ArcService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
|
||||
if (!arcService.arcExists(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(arcService.getDeletionImpact(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,4 +74,16 @@ public class CampaignController {
|
||||
campaignService.deleteCampaign(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récapitulatif des entités qui seront supprimées en cascade : utilisé par
|
||||
* l'UI pour afficher "X arcs, Y chapitres, Z scènes..." dans la confirmation.
|
||||
*/
|
||||
@GetMapping("/{id}/deletion-impact")
|
||||
public ResponseEntity<CampaignService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
|
||||
if (!campaignService.campaignExists(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(campaignService.getDeletionImpact(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,17 +40,11 @@ public class ChapterController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<ChapterDTO>> getAllChapters() {
|
||||
List<Chapter> chapters = chapterService.getAllChapters();
|
||||
List<ChapterDTO> chapterDTOs = chapters.stream()
|
||||
.map(chapterMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(chapterDTOs);
|
||||
}
|
||||
|
||||
@GetMapping("/arc/{arcId}")
|
||||
public ResponseEntity<List<ChapterDTO>> getChaptersByArcId(@PathVariable String arcId) {
|
||||
List<Chapter> chapters = chapterService.getChaptersByArcId(arcId);
|
||||
public ResponseEntity<List<ChapterDTO>> getAllChapters(
|
||||
@RequestParam(value = "arcId", required = false) String arcId) {
|
||||
List<Chapter> chapters = (arcId != null && !arcId.isBlank())
|
||||
? chapterService.getChaptersByArcId(arcId)
|
||||
: chapterService.getAllChapters();
|
||||
List<ChapterDTO> chapterDTOs = chapters.stream()
|
||||
.map(chapterMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
@@ -68,4 +62,12 @@ public class ChapterController {
|
||||
chapterService.deleteChapter(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/deletion-impact")
|
||||
public ResponseEntity<ChapterService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
|
||||
if (!chapterService.chapterExists(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(chapterService.getDeletionImpact(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Expose la configuration publique consommee par le frontend au demarrage.
|
||||
* Activer le mode demo via la variable d'env DEMO_MODE=true : le front
|
||||
* masque alors Settings / Export VTT, et les endpoints sensibles sont
|
||||
* verrouilles cote serveur (cf. SettingsController).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/config")
|
||||
public class ConfigController {
|
||||
|
||||
private final boolean demoMode;
|
||||
|
||||
public ConfigController(@Value("${app.demo-mode:false}") boolean demoMode) {
|
||||
this.demoMode = demoMode;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public Map<String, Object> getPublicConfig() {
|
||||
return Map.of("demoMode", demoMode);
|
||||
}
|
||||
}
|
||||
@@ -69,4 +69,17 @@ public class LoreController {
|
||||
loreService.deleteLore(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récapitulatif des entités qui seront supprimées / détachées en cascade.
|
||||
* Utilisé par l'UI pour afficher "X dossiers, Y pages, Z templates,
|
||||
* N campagne(s) détachée(s)" dans la confirmation.
|
||||
*/
|
||||
@GetMapping("/{id}/deletion-impact")
|
||||
public ResponseEntity<LoreService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
|
||||
if (loreService.getLoreById(id).isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(loreService.getDeletionImpact(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,4 +97,16 @@ public class LoreNodeController {
|
||||
loreNodeService.deleteLoreNode(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récapitulatif des entités qui seront supprimées en cascade : utilisé par
|
||||
* l'UI pour afficher "X sous-dossiers, Y pages..." dans la confirmation.
|
||||
*/
|
||||
@GetMapping("/{id}/deletion-impact")
|
||||
public ResponseEntity<LoreNodeService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
|
||||
if (loreNodeService.getLoreNodeById(id).isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(loreNodeService.getDeletionImpact(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,17 +40,11 @@ public class SceneController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<SceneDTO>> getAllScenes() {
|
||||
List<Scene> scenes = sceneService.getAllScenes();
|
||||
List<SceneDTO> sceneDTOs = scenes.stream()
|
||||
.map(sceneMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(sceneDTOs);
|
||||
}
|
||||
|
||||
@GetMapping("/chapter/{chapterId}")
|
||||
public ResponseEntity<List<SceneDTO>> getScenesByChapterId(@PathVariable String chapterId) {
|
||||
List<Scene> scenes = sceneService.getScenesByChapterId(chapterId);
|
||||
public ResponseEntity<List<SceneDTO>> getAllScenes(
|
||||
@RequestParam(value = "chapterId", required = false) String chapterId) {
|
||||
List<Scene> scenes = (chapterId != null && !chapterId.isBlank())
|
||||
? sceneService.getScenesByChapterId(chapterId)
|
||||
: sceneService.getAllScenes();
|
||||
List<SceneDTO> sceneDTOs = scenes.stream()
|
||||
.map(sceneMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
@@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@@ -32,20 +34,25 @@ public class SettingsController {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final String brainBaseUrl;
|
||||
private final boolean demoMode;
|
||||
|
||||
public SettingsController(RestTemplate restTemplate,
|
||||
@Value("${brain.base-url}") String brainBaseUrl) {
|
||||
@Value("${brain.base-url}") String brainBaseUrl,
|
||||
@Value("${app.demo-mode:false}") boolean demoMode) {
|
||||
this.restTemplate = restTemplate;
|
||||
this.brainBaseUrl = brainBaseUrl;
|
||||
this.demoMode = demoMode;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Map<String, Object>> getSettings() {
|
||||
guardDemoMode();
|
||||
return forward(HttpMethod.GET, "/settings", null);
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
public ResponseEntity<Map<String, Object>> updateSettings(@RequestBody Map<String, Object> patch) {
|
||||
guardDemoMode();
|
||||
return forward(HttpMethod.PUT, "/settings", patch);
|
||||
}
|
||||
|
||||
@@ -64,6 +71,12 @@ public class SettingsController {
|
||||
return forward(HttpMethod.GET, "/models/onemin", null);
|
||||
}
|
||||
|
||||
private void guardDemoMode() {
|
||||
if (demoMode) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Settings disabled in demo mode");
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
private ResponseEntity<Map<String, Object>> forward(HttpMethod method, String path, Object body) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
|
||||
@@ -21,13 +21,13 @@ spring.jpa.show-sql=true
|
||||
spring.jpa.properties.hibernate.format_sql=true
|
||||
|
||||
# Configuration CORS pour autoriser le Frontend Angular
|
||||
spring.web.cors.allowed-origins=http://localhost:4200
|
||||
spring.web.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:4200}
|
||||
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
|
||||
spring.web.cors.allowed-headers=*
|
||||
spring.web.cors.allow-credentials=true
|
||||
|
||||
# Configuration du Brain (service IA Python)
|
||||
brain.base-url=http://localhost:8000
|
||||
brain.base-url=${BRAIN_BASE_URL:http://localhost:8000}
|
||||
brain.timeout-seconds=120
|
||||
|
||||
# Secret partage Core <-> Brain (auth inter-service via entete X-Internal-Secret).
|
||||
@@ -50,3 +50,7 @@ minio.bucket=${MINIO_BUCKET:loremind-images}
|
||||
# Limites d'upload d'images (MB)
|
||||
spring.servlet.multipart.max-file-size=10MB
|
||||
spring.servlet.multipart.max-request-size=10MB
|
||||
|
||||
# Mode demo : masque Settings/Export cote front et bloque les PUT /api/settings
|
||||
# cote serveur. Activer via DEMO_MODE=true sur les deploiements publics.
|
||||
app.demo-mode=${DEMO_MODE:false}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
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 org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -14,6 +18,7 @@ import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
@@ -26,6 +31,10 @@ public class ArcServiceTest {
|
||||
|
||||
@Mock
|
||||
private ArcRepository arcRepository;
|
||||
@Mock
|
||||
private ChapterRepository chapterRepository;
|
||||
@Mock
|
||||
private SceneRepository sceneRepository;
|
||||
|
||||
@InjectMocks
|
||||
private ArcService arcService;
|
||||
@@ -159,15 +168,48 @@ public class ArcServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteArc() {
|
||||
// Arrange
|
||||
doNothing().when(arcRepository).deleteById("arc-1");
|
||||
|
||||
// Act
|
||||
void testDeleteArc_EmptyArc() {
|
||||
// Aucun chapitre : Mockito renvoie List.of() par défaut.
|
||||
arcService.deleteArc("arc-1");
|
||||
|
||||
// Assert
|
||||
verify(arcRepository, times(1)).deleteById("arc-1");
|
||||
verify(arcRepository).deleteById("arc-1");
|
||||
verify(chapterRepository, never()).deleteById(anyString());
|
||||
verify(sceneRepository, never()).deleteById(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteArc_CascadesChaptersAndScenes() {
|
||||
Chapter chapter = Chapter.builder().id("chap-1").arcId("arc-1").name("C").build();
|
||||
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
|
||||
Scene s2 = Scene.builder().id("s-2").chapterId("chap-1").name("S2").build();
|
||||
|
||||
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(chapter));
|
||||
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1, s2));
|
||||
|
||||
arcService.deleteArc("arc-1");
|
||||
|
||||
verify(sceneRepository).deleteById("s-1");
|
||||
verify(sceneRepository).deleteById("s-2");
|
||||
verify(chapterRepository).deleteById("chap-1");
|
||||
verify(arcRepository).deleteById("arc-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetDeletionImpact() {
|
||||
Chapter c1 = Chapter.builder().id("chap-1").arcId("arc-1").name("C1").build();
|
||||
Chapter c2 = Chapter.builder().id("chap-2").arcId("arc-1").name("C2").build();
|
||||
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
|
||||
Scene s2 = Scene.builder().id("s-2").chapterId("chap-2").name("S2").build();
|
||||
Scene s3 = Scene.builder().id("s-3").chapterId("chap-2").name("S3").build();
|
||||
|
||||
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(c1, c2));
|
||||
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1));
|
||||
when(sceneRepository.findByChapterId("chap-2")).thenReturn(List.of(s2, s3));
|
||||
|
||||
ArcService.DeletionImpact impact = arcService.getDeletionImpact("arc-1");
|
||||
|
||||
assertEquals(2, impact.chapters());
|
||||
assertEquals(3, impact.scenes());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.Character;
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
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.CharacterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -27,6 +35,14 @@ public class CampaignServiceTest {
|
||||
|
||||
@Mock
|
||||
private CampaignRepository campaignRepository;
|
||||
@Mock
|
||||
private ArcRepository arcRepository;
|
||||
@Mock
|
||||
private ChapterRepository chapterRepository;
|
||||
@Mock
|
||||
private SceneRepository sceneRepository;
|
||||
@Mock
|
||||
private CharacterRepository characterRepository;
|
||||
|
||||
@InjectMocks
|
||||
private CampaignService campaignService;
|
||||
@@ -53,7 +69,10 @@ public class CampaignServiceTest {
|
||||
"lore-123",
|
||||
null
|
||||
);
|
||||
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
|
||||
// Le repo renvoie la Campaign telle que passée — on teste la normalisation
|
||||
// du loreId dans le service, pas le comportement du repo.
|
||||
when(campaignRepository.save(any(Campaign.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
// Act
|
||||
Campaign result = campaignService.createCampaign(data);
|
||||
@@ -73,7 +92,8 @@ public class CampaignServiceTest {
|
||||
null,
|
||||
null
|
||||
);
|
||||
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
|
||||
when(campaignRepository.save(any(Campaign.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
// Act
|
||||
Campaign result = campaignService.createCampaign(data);
|
||||
@@ -93,7 +113,8 @@ public class CampaignServiceTest {
|
||||
" ",
|
||||
null
|
||||
);
|
||||
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
|
||||
when(campaignRepository.save(any(Campaign.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
// Act
|
||||
Campaign result = campaignService.createCampaign(data);
|
||||
@@ -191,15 +212,75 @@ public class CampaignServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteCampaign() {
|
||||
// Arrange
|
||||
doNothing().when(campaignRepository).deleteById("campaign-1");
|
||||
|
||||
void testDeleteCampaign_EmptyCampaign() {
|
||||
// Arrange : aucune dépendance ; Mockito renvoie List.of() par défaut.
|
||||
// Act
|
||||
campaignService.deleteCampaign("campaign-1");
|
||||
|
||||
// Assert
|
||||
verify(campaignRepository, times(1)).deleteById("campaign-1");
|
||||
verify(arcRepository, never()).deleteById(anyString());
|
||||
verify(chapterRepository, never()).deleteById(anyString());
|
||||
verify(sceneRepository, never()).deleteById(anyString());
|
||||
verify(characterRepository, never()).deleteById(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteCampaign_CascadesArcsChaptersScenes() {
|
||||
// Arrange : campagne avec 1 arc → 1 chapitre → 2 scènes.
|
||||
Arc arc = Arc.builder().id("arc-1").campaignId("campaign-1").name("Arc 1").build();
|
||||
Chapter chapter = Chapter.builder().id("chap-1").arcId("arc-1").name("Chap 1").build();
|
||||
Scene scene1 = Scene.builder().id("scene-1").chapterId("chap-1").name("Scene 1").build();
|
||||
Scene scene2 = Scene.builder().id("scene-2").chapterId("chap-1").name("Scene 2").build();
|
||||
|
||||
when(arcRepository.findByCampaignId("campaign-1")).thenReturn(List.of(arc));
|
||||
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(chapter));
|
||||
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(scene1, scene2));
|
||||
|
||||
// Act
|
||||
campaignService.deleteCampaign("campaign-1");
|
||||
|
||||
// Assert : tout disparaît, dans l'ordre feuilles → racine.
|
||||
verify(sceneRepository).deleteById("scene-1");
|
||||
verify(sceneRepository).deleteById("scene-2");
|
||||
verify(chapterRepository).deleteById("chap-1");
|
||||
verify(arcRepository).deleteById("arc-1");
|
||||
verify(campaignRepository).deleteById("campaign-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteCampaign_CascadesCharacters() {
|
||||
Character pc = Character.builder().id("char-1").campaignId("campaign-1").name("Alric").build();
|
||||
when(characterRepository.findByCampaignId("campaign-1")).thenReturn(List.of(pc));
|
||||
|
||||
campaignService.deleteCampaign("campaign-1");
|
||||
|
||||
verify(characterRepository).deleteById("char-1");
|
||||
verify(campaignRepository).deleteById("campaign-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetDeletionImpact() {
|
||||
Arc arc = Arc.builder().id("arc-1").campaignId("campaign-1").name("Arc 1").build();
|
||||
Chapter c1 = Chapter.builder().id("chap-1").arcId("arc-1").name("C1").build();
|
||||
Chapter c2 = Chapter.builder().id("chap-2").arcId("arc-1").name("C2").build();
|
||||
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
|
||||
Scene s2 = Scene.builder().id("s-2").chapterId("chap-2").name("S2").build();
|
||||
Scene s3 = Scene.builder().id("s-3").chapterId("chap-2").name("S3").build();
|
||||
Character pc = Character.builder().id("char-1").campaignId("campaign-1").name("Alric").build();
|
||||
|
||||
when(arcRepository.findByCampaignId("campaign-1")).thenReturn(List.of(arc));
|
||||
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(c1, c2));
|
||||
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1));
|
||||
when(sceneRepository.findByChapterId("chap-2")).thenReturn(List.of(s2, s3));
|
||||
when(characterRepository.findByCampaignId("campaign-1")).thenReturn(List.of(pc));
|
||||
|
||||
CampaignService.DeletionImpact impact = campaignService.getDeletionImpact("campaign-1");
|
||||
|
||||
assertEquals(1, impact.arcs());
|
||||
assertEquals(2, impact.chapters());
|
||||
assertEquals(3, impact.scenes());
|
||||
assertEquals(1, impact.characters());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -14,6 +16,7 @@ import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
@@ -26,6 +29,8 @@ public class ChapterServiceTest {
|
||||
|
||||
@Mock
|
||||
private ChapterRepository chapterRepository;
|
||||
@Mock
|
||||
private SceneRepository sceneRepository;
|
||||
|
||||
@InjectMocks
|
||||
private ChapterService chapterService;
|
||||
@@ -157,15 +162,36 @@ public class ChapterServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteChapter() {
|
||||
// Arrange
|
||||
doNothing().when(chapterRepository).deleteById("chapter-1");
|
||||
|
||||
// Act
|
||||
void testDeleteChapter_EmptyChapter() {
|
||||
// Aucune scène : Mockito renvoie List.of() par défaut.
|
||||
chapterService.deleteChapter("chapter-1");
|
||||
|
||||
// Assert
|
||||
verify(chapterRepository, times(1)).deleteById("chapter-1");
|
||||
verify(chapterRepository).deleteById("chapter-1");
|
||||
verify(sceneRepository, never()).deleteById(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteChapter_CascadesScenes() {
|
||||
Scene s1 = Scene.builder().id("s-1").chapterId("chapter-1").name("S1").build();
|
||||
Scene s2 = Scene.builder().id("s-2").chapterId("chapter-1").name("S2").build();
|
||||
when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(s1, s2));
|
||||
|
||||
chapterService.deleteChapter("chapter-1");
|
||||
|
||||
verify(sceneRepository).deleteById("s-1");
|
||||
verify(sceneRepository).deleteById("s-2");
|
||||
verify(chapterRepository).deleteById("chapter-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetDeletionImpact() {
|
||||
Scene s1 = Scene.builder().id("s-1").chapterId("chapter-1").name("S1").build();
|
||||
Scene s2 = Scene.builder().id("s-2").chapterId("chapter-1").name("S2").build();
|
||||
when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(s1, s2));
|
||||
|
||||
ChapterService.DeletionImpact impact = chapterService.getDeletionImpact("chapter-1");
|
||||
|
||||
assertEquals(2, impact.scenes());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.CharacterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -40,6 +41,8 @@ public class CampaignStructuralContextBuilderTest {
|
||||
private ChapterRepository chapterRepository;
|
||||
@Mock
|
||||
private SceneRepository sceneRepository;
|
||||
@Mock
|
||||
private CharacterRepository characterRepository;
|
||||
|
||||
@InjectMocks
|
||||
private CampaignStructuralContextBuilder builder;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.loremind.application.lorecontext;
|
||||
|
||||
import com.loremind.domain.lorecontext.LoreNode;
|
||||
import com.loremind.domain.lorecontext.Page;
|
||||
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||
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;
|
||||
@@ -15,6 +17,7 @@ import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
@@ -26,6 +29,7 @@ import static org.mockito.Mockito.*;
|
||||
public class LoreNodeServiceTest {
|
||||
|
||||
@Mock private LoreNodeRepository loreNodeRepository;
|
||||
@Mock private PageRepository pageRepository;
|
||||
|
||||
@InjectMocks private LoreNodeService loreNodeService;
|
||||
|
||||
@@ -118,8 +122,66 @@ public class LoreNodeServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDelete() {
|
||||
void testDelete_LeafFolder() {
|
||||
// Aucun descendant, aucune page : seul le dossier est supprimé.
|
||||
loreNodeService.deleteLoreNode("n-1");
|
||||
verify(loreNodeRepository).deleteById("n-1");
|
||||
verify(pageRepository, never()).deleteById(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDelete_CascadesPagesOfRoot() {
|
||||
Page p1 = Page.builder().id("p-1").nodeId("n-1").title("P1").build();
|
||||
Page p2 = Page.builder().id("p-2").nodeId("n-1").title("P2").build();
|
||||
when(pageRepository.findByNodeId("n-1")).thenReturn(List.of(p1, p2));
|
||||
|
||||
loreNodeService.deleteLoreNode("n-1");
|
||||
|
||||
verify(pageRepository).deleteById("p-1");
|
||||
verify(pageRepository).deleteById("p-2");
|
||||
verify(loreNodeRepository).deleteById("n-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDelete_CascadesSubfoldersRecursive() {
|
||||
// n-1 → n-1a → n-1a1 ; chaque feuille a une page.
|
||||
LoreNode mid = LoreNode.builder().id("n-1a").parentId("n-1").loreId("lore-1").name("mid").build();
|
||||
LoreNode leaf = LoreNode.builder().id("n-1a1").parentId("n-1a").loreId("lore-1").name("leaf").build();
|
||||
Page pageOnLeaf = Page.builder().id("p-leaf").nodeId("n-1a1").title("P").build();
|
||||
|
||||
when(loreNodeRepository.findByParentId("n-1")).thenReturn(List.of(mid));
|
||||
when(loreNodeRepository.findByParentId("n-1a")).thenReturn(List.of(leaf));
|
||||
when(pageRepository.findByNodeId("n-1a1")).thenReturn(List.of(pageOnLeaf));
|
||||
|
||||
loreNodeService.deleteLoreNode("n-1");
|
||||
|
||||
// Feuilles d'abord (pages puis dossier leaf), puis mid, puis la racine.
|
||||
verify(pageRepository).deleteById("p-leaf");
|
||||
verify(loreNodeRepository).deleteById("n-1a1");
|
||||
verify(loreNodeRepository).deleteById("n-1a");
|
||||
verify(loreNodeRepository).deleteById("n-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetDeletionImpact_CountsSubfoldersAndPages() {
|
||||
LoreNode sub1 = LoreNode.builder().id("s-1").parentId("n-1").loreId("lore-1").name("s1").build();
|
||||
LoreNode sub2 = LoreNode.builder().id("s-2").parentId("n-1").loreId("lore-1").name("s2").build();
|
||||
LoreNode subsub = LoreNode.builder().id("s-1a").parentId("s-1").loreId("lore-1").name("s1a").build();
|
||||
Page p1 = Page.builder().id("p-1").nodeId("n-1").title("P1").build();
|
||||
Page p2 = Page.builder().id("p-2").nodeId("s-1").title("P2").build();
|
||||
Page p3 = Page.builder().id("p-3").nodeId("s-1a").title("P3").build();
|
||||
|
||||
when(loreNodeRepository.findByParentId("n-1")).thenReturn(List.of(sub1, sub2));
|
||||
when(loreNodeRepository.findByParentId("s-1")).thenReturn(List.of(subsub));
|
||||
when(pageRepository.findByNodeId("n-1")).thenReturn(List.of(p1));
|
||||
when(pageRepository.findByNodeId("s-1")).thenReturn(List.of(p2));
|
||||
when(pageRepository.findByNodeId("s-2")).thenReturn(List.of());
|
||||
when(pageRepository.findByNodeId("s-1a")).thenReturn(List.of(p3));
|
||||
|
||||
LoreNodeService.DeletionImpact impact = loreNodeService.getDeletionImpact("n-1");
|
||||
|
||||
// 3 sous-dossiers (sub1, sub2, subsub) — on ne compte pas la racine n-1.
|
||||
assertEquals(3, impact.folders());
|
||||
assertEquals(3, impact.pages());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package com.loremind.application.lorecontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
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;
|
||||
@@ -17,6 +23,7 @@ import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
@@ -30,6 +37,8 @@ public class LoreServiceTest {
|
||||
@Mock private LoreRepository loreRepository;
|
||||
@Mock private LoreNodeRepository loreNodeRepository;
|
||||
@Mock private PageRepository pageRepository;
|
||||
@Mock private TemplateRepository templateRepository;
|
||||
@Mock private CampaignRepository campaignRepository;
|
||||
|
||||
@InjectMocks private LoreService loreService;
|
||||
|
||||
@@ -134,8 +143,67 @@ public class LoreServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteLore_DelegatesToRepository() {
|
||||
void testDeleteLore_EmptyLore() {
|
||||
// Aucun dossier / page / template / campagne : seul le Lore est supprimé.
|
||||
loreService.deleteLore("lore-1");
|
||||
verify(loreRepository).deleteById("lore-1");
|
||||
verify(loreNodeRepository, never()).deleteById(anyString());
|
||||
verify(pageRepository, never()).deleteById(anyString());
|
||||
verify(templateRepository, never()).deleteById(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteLore_CascadesFoldersPagesTemplates() {
|
||||
LoreNode node = LoreNode.builder().id("n-1").loreId("lore-1").name("F").build();
|
||||
Page page = Page.builder().id("p-1").loreId("lore-1").nodeId("n-1").title("P").build();
|
||||
Template template = Template.builder().id("t-1").loreId("lore-1").name("T").build();
|
||||
|
||||
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(page));
|
||||
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(node));
|
||||
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(template));
|
||||
|
||||
loreService.deleteLore("lore-1");
|
||||
|
||||
verify(pageRepository).deleteById("p-1");
|
||||
verify(loreNodeRepository).deleteById("n-1");
|
||||
verify(templateRepository).deleteById("t-1");
|
||||
verify(loreRepository).deleteById("lore-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteLore_DetachesCampaignsInsteadOfDeleting() {
|
||||
Campaign attached = Campaign.builder().id("c-1").loreId("lore-1").name("C1").build();
|
||||
Campaign other = Campaign.builder().id("c-2").loreId("lore-other").name("C2").build();
|
||||
Campaign orphan = Campaign.builder().id("c-3").loreId(null).name("C3").build();
|
||||
when(campaignRepository.findAll()).thenReturn(List.of(attached, other, orphan));
|
||||
when(campaignRepository.save(any(Campaign.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
loreService.deleteLore("lore-1");
|
||||
|
||||
// Seule la campagne attachée est re-sauvegardée (avec loreId=null).
|
||||
ArgumentCaptor<Campaign> captor = ArgumentCaptor.forClass(Campaign.class);
|
||||
verify(campaignRepository, times(1)).save(captor.capture());
|
||||
assertEquals("c-1", captor.getValue().getId());
|
||||
assertNull(captor.getValue().getLoreId());
|
||||
// Aucune campagne n'est supprimée.
|
||||
verify(campaignRepository, never()).deleteById(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetDeletionImpact() {
|
||||
Template t1 = Template.builder().id("t-1").loreId("lore-1").name("T").build();
|
||||
Campaign attached = Campaign.builder().id("c-1").loreId("lore-1").name("C").build();
|
||||
Campaign unrelated = Campaign.builder().id("c-2").loreId("lore-other").name("C2").build();
|
||||
when(loreNodeRepository.countByLoreId("lore-1")).thenReturn(4L);
|
||||
when(pageRepository.countByLoreId("lore-1")).thenReturn(12L);
|
||||
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(t1));
|
||||
when(campaignRepository.findAll()).thenReturn(List.of(attached, unrelated));
|
||||
|
||||
LoreService.DeletionImpact impact = loreService.getDeletionImpact("lore-1");
|
||||
|
||||
assertEquals(4, impact.folders());
|
||||
assertEquals(12, impact.pages());
|
||||
assertEquals(1, impact.templates());
|
||||
assertEquals(1, impact.detachedCampaigns());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ class ArcControllerTest {
|
||||
@Test
|
||||
void getByCampaign_pathVariant() throws Exception {
|
||||
arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build());
|
||||
mockMvc.perform(get("/api/arcs/campaign/{id}", campaignId))
|
||||
mockMvc.perform(get("/api/arcs").param("campaignId", campaignId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray());
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ class ChapterControllerTest {
|
||||
@Test
|
||||
void getByArc_pathVariant() throws Exception {
|
||||
chapterRepository.save(Chapter.builder().arcId(arcId).name("A").order(0).build());
|
||||
mockMvc.perform(get("/api/chapters/arc/{id}", arcId))
|
||||
mockMvc.perform(get("/api/chapters").param("arcId", arcId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray());
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ class SceneControllerTest {
|
||||
@Test
|
||||
void getByChapter_pathVariant() throws Exception {
|
||||
sceneRepository.save(Scene.builder().chapterId(chapterId).name("A").order(0).build());
|
||||
mockMvc.perform(get("/api/scenes/chapter/{id}", chapterId))
|
||||
mockMvc.perform(get("/api/scenes").param("chapterId", chapterId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray());
|
||||
}
|
||||
|
||||
27
demo/.env.example
Normal file
27
demo/.env.example
Normal file
@@ -0,0 +1,27 @@
|
||||
# Copie en .env sur le serveur (jamais commite).
|
||||
|
||||
# Registre et tag des images core / brain a spawner par session.
|
||||
REGISTRY=git.igmlcreation.fr
|
||||
TAG=latest
|
||||
|
||||
# Secret partage entre core et brain (genere aleatoirement au build de chaque
|
||||
# session, mais un defaut est utile pour les checks de sante au boot).
|
||||
BRAIN_INTERNAL_SECRET_DEFAULT=change-me-on-server
|
||||
|
||||
# Capacite
|
||||
MAX_SESSIONS=10
|
||||
SESSION_TTL_MINUTES=20
|
||||
|
||||
# Rate limiting : 1 creation de session par IP par fenetre (secondes).
|
||||
RATE_LIMIT_WINDOW_SECONDS=60
|
||||
|
||||
# Limites par conteneur de session (Docker API)
|
||||
CORE_MEMORY_MB=700
|
||||
BRAIN_MEMORY_MB=300
|
||||
POSTGRES_MEMORY_MB=200
|
||||
|
||||
# Nom du reseau Docker externe Traefik (doit exister avant docker compose up)
|
||||
TRAEFIK_NETWORK=traefik
|
||||
|
||||
# Domaine expose par Traefik
|
||||
DEMO_HOST=loremind-demo.igmlcreation.fr
|
||||
2
demo/.gitignore
vendored
Normal file
2
demo/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
*.log
|
||||
46
demo/README.md
Normal file
46
demo/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Demo publique LoreMind
|
||||
|
||||
Deploiement d'une instance de demo ephemere pour `loremind-demo.igmlcreation.fr`.
|
||||
|
||||
## Principe
|
||||
|
||||
Chaque visiteur recoit un environnement isole spawne a la volee, detruit apres
|
||||
un court delai d'inactivite. Les donnees ne sont jamais persistees.
|
||||
|
||||
Le mode demo (variable d'env `DEMO_MODE=true` sur le core) masque les ecrans
|
||||
de configuration qui n'ont pas de sens en vitrine.
|
||||
|
||||
## Deploiement
|
||||
|
||||
Prerequis :
|
||||
- Reseau Traefik existant cote host
|
||||
- Images `core` et `brain` pushees au registre
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Ajuster .env
|
||||
docker compose -f docker-compose.infra.yml up -d --build
|
||||
```
|
||||
|
||||
Premier build : 5-10 min. Suivants : incrementaux.
|
||||
|
||||
## Mise a jour
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.infra.yml pull
|
||||
docker compose -f docker-compose.infra.yml up -d --build
|
||||
```
|
||||
|
||||
Les sessions en cours sont tuees au redemarrage.
|
||||
|
||||
## Observations
|
||||
|
||||
- `docker logs loremind-demo-orchestrator -f`
|
||||
- `docker ps --filter "name=demo-"`
|
||||
|
||||
## Desactiver
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.infra.yml down
|
||||
docker ps -q --filter "name=demo-" | xargs -r docker rm -f
|
||||
```
|
||||
80
demo/docker-compose.infra.yml
Normal file
80
demo/docker-compose.infra.yml
Normal file
@@ -0,0 +1,80 @@
|
||||
# ==========================================================================
|
||||
# LoreMind Demo - Infra permanente
|
||||
# --------------------------------------------------------------------------
|
||||
# - dockerproxy : expose un subset restreint de l'API Docker a l'orchestrateur
|
||||
# (lecture seule sauf containers/images/networks). Remplace le mount direct
|
||||
# de /var/run/docker.sock : meme avec RCE sur l'orchestrateur, un attaquant
|
||||
# ne peut pas exec sur l'hote, creer des volumes, ni lire le daemon.
|
||||
# - orchestrator : sert l'Angular et proxy les /api/* vers les sessions.
|
||||
#
|
||||
# Les conteneurs de session sont crees dynamiquement par l'orchestrateur.
|
||||
# ==========================================================================
|
||||
|
||||
services:
|
||||
dockerproxy:
|
||||
image: tecnativa/docker-socket-proxy:latest
|
||||
container_name: loremind-demo-dockerproxy
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Minimum requis par l'orchestrateur.
|
||||
CONTAINERS: 1
|
||||
IMAGES: 1
|
||||
NETWORKS: 1
|
||||
POST: 1
|
||||
# Tout le reste reste a 0 (defaut) : pas d'EXEC, VOLUMES, BUILD, AUTH,
|
||||
# SYSTEM, INFO, SWARM, SECRETS, CONFIGS, NODES, etc.
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- socket-proxy
|
||||
# Pas de ports exposes : accessible uniquement via le reseau socket-proxy.
|
||||
|
||||
orchestrator:
|
||||
container_name: loremind-demo-orchestrator
|
||||
depends_on:
|
||||
- dockerproxy
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: demo/orchestrator/Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# L'orchestrateur parle a dockerproxy au lieu du socket direct.
|
||||
DOCKER_HOST: tcp://dockerproxy:2375
|
||||
REGISTRY: ${REGISTRY:-git.igmlcreation.fr}
|
||||
TAG: ${TAG:-latest}
|
||||
MAX_SESSIONS: ${MAX_SESSIONS:-10}
|
||||
SESSION_TTL_MINUTES: ${SESSION_TTL_MINUTES:-20}
|
||||
CORE_MEMORY_MB: ${CORE_MEMORY_MB:-700}
|
||||
BRAIN_MEMORY_MB: ${BRAIN_MEMORY_MB:-300}
|
||||
POSTGRES_MEMORY_MB: ${POSTGRES_MEMORY_MB:-200}
|
||||
SESSIONS_NETWORK: loremind-demo-sessions
|
||||
BRAIN_INTERNAL_SECRET_DEFAULT: ${BRAIN_INTERNAL_SECRET_DEFAULT:-change-me}
|
||||
# Rate limit : 1 creation par IP par fenetre (en secondes).
|
||||
RATE_LIMIT_WINDOW_SECONDS: ${RATE_LIMIT_WINDOW_SECONDS:-60}
|
||||
# Domaine public : propage aux cores de session pour configurer CORS.
|
||||
DEMO_HOST: ${DEMO_HOST:-loremind-demo.igmlcreation.fr}
|
||||
networks:
|
||||
- traefik
|
||||
- sessions
|
||||
- socket-proxy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.loremind-demo.rule=Host(`${DEMO_HOST:-loremind-demo.igmlcreation.fr}`)"
|
||||
- "traefik.http.routers.loremind-demo.entrypoints=websecure"
|
||||
- "traefik.http.routers.loremind-demo.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.loremind-demo.loadbalancer.server.port=80"
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
external: true
|
||||
name: ${TRAEFIK_NETWORK:-traefik}
|
||||
sessions:
|
||||
# Reseau interne pour les trios de session. Pas d'acces Internet direct
|
||||
# (sauf via le DNS Docker), pas expose au host.
|
||||
name: loremind-demo-sessions
|
||||
driver: bridge
|
||||
socket-proxy:
|
||||
# Reseau prive entre dockerproxy et orchestrateur. Isole du reste.
|
||||
name: loremind-demo-socket-proxy
|
||||
driver: bridge
|
||||
internal: true
|
||||
32
demo/orchestrator/Dockerfile
Normal file
32
demo/orchestrator/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
# Build context attendu : racine du repo LoreMind.
|
||||
# Appele depuis demo/docker-compose.infra.yml avec context: ../
|
||||
|
||||
# --- Etage 1 : build Angular statique ---
|
||||
FROM node:20-alpine AS web-build
|
||||
WORKDIR /build
|
||||
COPY web/package*.json ./
|
||||
RUN npm ci
|
||||
COPY web/ .
|
||||
RUN npm run build -- --configuration production
|
||||
|
||||
# --- Etage 2 : build orchestrateur Go ---
|
||||
# go 1.25+ requis par une dependance transitive de github.com/docker/docker
|
||||
# (otelhttp v0.68+ impose cette version minimale).
|
||||
FROM golang:1.25-alpine AS go-build
|
||||
WORKDIR /src
|
||||
COPY demo/orchestrator/ ./
|
||||
# go mod tidy resout le go.sum au build pour eviter d'avoir a le committer.
|
||||
RUN go mod tidy && CGO_ENABLED=0 go build -o /orchestrator .
|
||||
|
||||
# --- Etage final : runtime minimal ---
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates
|
||||
WORKDIR /app
|
||||
COPY --from=go-build /orchestrator /app/orchestrator
|
||||
COPY --from=web-build /build/dist/web /app/static
|
||||
COPY demo/orchestrator/preparing.html /app/preparing.html
|
||||
EXPOSE 80
|
||||
ENV STATIC_DIR=/app/static \
|
||||
PREPARING_PAGE=/app/preparing.html
|
||||
ENTRYPOINT ["/app/orchestrator"]
|
||||
68
demo/orchestrator/config.go
Normal file
68
demo/orchestrator/config.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config centralise les parametres lus depuis les variables d'env au boot.
|
||||
type Config struct {
|
||||
Registry string
|
||||
Tag string
|
||||
MaxSessions int
|
||||
SessionTTL time.Duration
|
||||
CoreMemoryBytes int64
|
||||
BrainMemoryBytes int64
|
||||
PostgresMemoryBytes int64
|
||||
SessionsNetwork string
|
||||
BrainSecretDefault string
|
||||
StaticDir string
|
||||
PreparingPage string
|
||||
RateLimitWindow time.Duration
|
||||
MaxBodyBytes int64
|
||||
DemoHost string
|
||||
}
|
||||
|
||||
func loadConfig() *Config {
|
||||
return &Config{
|
||||
Registry: envStr("REGISTRY", "git.igmlcreation.fr"),
|
||||
Tag: envStr("TAG", "latest"),
|
||||
MaxSessions: envInt("MAX_SESSIONS", 10),
|
||||
SessionTTL: time.Duration(envInt("SESSION_TTL_MINUTES", 20)) * time.Minute,
|
||||
CoreMemoryBytes: int64(envInt("CORE_MEMORY_MB", 700)) * 1024 * 1024,
|
||||
BrainMemoryBytes: int64(envInt("BRAIN_MEMORY_MB", 300)) * 1024 * 1024,
|
||||
PostgresMemoryBytes: int64(envInt("POSTGRES_MEMORY_MB", 200)) * 1024 * 1024,
|
||||
SessionsNetwork: envStr("SESSIONS_NETWORK", "loremind-demo-sessions"),
|
||||
BrainSecretDefault: envStr("BRAIN_INTERNAL_SECRET_DEFAULT", "change-me"),
|
||||
StaticDir: envStr("STATIC_DIR", "/app/static"),
|
||||
PreparingPage: envStr("PREPARING_PAGE", "/app/preparing.html"),
|
||||
RateLimitWindow: time.Duration(envInt("RATE_LIMIT_WINDOW_SECONDS", 60)) * time.Second,
|
||||
// 10 Mo : aligne avec la limite d'upload d'image cote core.
|
||||
MaxBodyBytes: int64(envInt("MAX_BODY_MB", 10)) * 1024 * 1024,
|
||||
// Utilise pour injecter APP_CORS_ALLOWED_ORIGINS dans les cores spawnes :
|
||||
// sans ca, Spring bloque les POST avec 403 (origine rejetee).
|
||||
DemoHost: envStr("DEMO_HOST", "loremind-demo.igmlcreation.fr"),
|
||||
}
|
||||
}
|
||||
|
||||
func envStr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envInt(key string, def int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
i, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
log.Printf("warning: env %s=%q not a number, using default %d", key, v, def)
|
||||
return def
|
||||
}
|
||||
return i
|
||||
}
|
||||
339
demo/orchestrator/docker.go
Normal file
339
demo/orchestrator/docker.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DockerClient parle a l'API Engine Docker en HTTP brut via le dockerproxy.
|
||||
// Pas de SDK externe : evite les conflits de versions transitives qui
|
||||
// rendaient github.com/docker/docker v27/v28 ininstallable proprement.
|
||||
//
|
||||
// L'API Engine v1.43 est exposee par Docker Engine 24+ (et le dockerproxy
|
||||
// la supporte sans config supplementaire).
|
||||
type DockerClient struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newDockerClient() (*DockerClient, error) {
|
||||
base := os.Getenv("DOCKER_HOST")
|
||||
if base == "" {
|
||||
return nil, fmt.Errorf("DOCKER_HOST non defini (attendu : tcp://dockerproxy:2375)")
|
||||
}
|
||||
// tcp://host:port -> http://host:port (le dockerproxy parle HTTP en clair).
|
||||
base = strings.Replace(base, "tcp://", "http://", 1)
|
||||
return &DockerClient{
|
||||
baseURL: strings.TrimRight(base, "/") + "/v1.43",
|
||||
http: &http.Client{Timeout: 60 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Types serialises vers l'API Engine ---
|
||||
|
||||
type containerSpec struct {
|
||||
Image string `json:"Image"`
|
||||
Env []string `json:"Env,omitempty"`
|
||||
Labels map[string]string `json:"Labels,omitempty"`
|
||||
HostConfig hostConfig `json:"HostConfig"`
|
||||
NetworkingConfig networkingConfig `json:"NetworkingConfig"`
|
||||
}
|
||||
|
||||
type hostConfig struct {
|
||||
Memory int64 `json:"Memory,omitempty"`
|
||||
NanoCPUs int64 `json:"NanoCPUs,omitempty"`
|
||||
PidsLimit int64 `json:"PidsLimit,omitempty"`
|
||||
Tmpfs map[string]string `json:"Tmpfs,omitempty"`
|
||||
SecurityOpt []string `json:"SecurityOpt,omitempty"`
|
||||
RestartPolicy restartPolicy `json:"RestartPolicy"`
|
||||
}
|
||||
|
||||
type restartPolicy struct {
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
|
||||
type networkingConfig struct {
|
||||
EndpointsConfig map[string]endpointSettings `json:"EndpointsConfig,omitempty"`
|
||||
}
|
||||
|
||||
type endpointSettings struct {
|
||||
Aliases []string `json:"Aliases,omitempty"`
|
||||
}
|
||||
|
||||
// runSpec : forme intermediate cote orchestrateur, mappee sur containerSpec
|
||||
// au moment d'envoyer la requete.
|
||||
type runSpec struct {
|
||||
Name string
|
||||
Image string
|
||||
Env []string
|
||||
Labels map[string]string
|
||||
Memory int64
|
||||
Tmpfs map[string]string
|
||||
Net string
|
||||
Alias string
|
||||
}
|
||||
|
||||
// --- Operations de haut niveau ---
|
||||
|
||||
// SpawnTrio cree postgres + brain + core pour une session.
|
||||
func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Config) error {
|
||||
pgName := "demo-" + sessionID + "-postgres"
|
||||
brainName := "demo-" + sessionID + "-brain"
|
||||
coreName := "demo-" + sessionID + "-core"
|
||||
pgPassword := randomHex(16)
|
||||
brainSecret := randomHex(32)
|
||||
adminPassword := randomHex(16)
|
||||
|
||||
labels := map[string]string{"demo-session": sessionID}
|
||||
|
||||
if err := d.runContainer(ctx, runSpec{
|
||||
Name: pgName,
|
||||
Image: "postgres:16-alpine",
|
||||
Env: []string{"POSTGRES_DB=loremind", "POSTGRES_USER=loremind", "POSTGRES_PASSWORD=" + pgPassword},
|
||||
Labels: copyLabels(labels, "postgres"),
|
||||
Memory: cfg.PostgresMemoryBytes,
|
||||
Tmpfs: map[string]string{"/var/lib/postgresql/data": "rw,size=200m"},
|
||||
Net: cfg.SessionsNetwork,
|
||||
Alias: pgName,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("spawn postgres: %w", err)
|
||||
}
|
||||
|
||||
if err := d.runContainer(ctx, runSpec{
|
||||
Name: brainName,
|
||||
Image: cfg.Registry + "/ietm64/brain:" + cfg.Tag,
|
||||
Env: []string{
|
||||
"INTERNAL_SHARED_SECRET=" + brainSecret,
|
||||
// Pas de provider LLM configure en demo : les features IA echoueront
|
||||
// proprement, la demo sert principalement a explorer l'edition.
|
||||
"LLM_PROVIDER=ollama",
|
||||
"OLLAMA_BASE_URL=http://localhost:1",
|
||||
},
|
||||
Labels: copyLabels(labels, "brain"),
|
||||
Memory: cfg.BrainMemoryBytes,
|
||||
Net: cfg.SessionsNetwork,
|
||||
Alias: brainName,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("spawn brain: %w", err)
|
||||
}
|
||||
|
||||
if err := d.runContainer(ctx, runSpec{
|
||||
Name: coreName,
|
||||
Image: cfg.Registry + "/ietm64/core:" + cfg.Tag,
|
||||
Env: []string{
|
||||
"SPRING_DATASOURCE_URL=jdbc:postgresql://" + pgName + ":5432/loremind",
|
||||
"SPRING_DATASOURCE_USERNAME=loremind",
|
||||
"SPRING_DATASOURCE_PASSWORD=" + pgPassword,
|
||||
"BRAIN_BASE_URL=http://" + brainName + ":8000",
|
||||
"BRAIN_INTERNAL_SECRET=" + brainSecret,
|
||||
"ADMIN_USERNAME=admin",
|
||||
"ADMIN_PASSWORD=" + adminPassword,
|
||||
"DEMO_MODE=true",
|
||||
// CorsConfig.java lit app.cors.allowed-origins (= APP_CORS_ALLOWED_ORIGINS
|
||||
// via le relaxed binding Spring). Necessaire meme en same-origin car
|
||||
// le browser envoie Origin sur les POST et le CorsFilter 403 les
|
||||
// origines inconnues.
|
||||
"APP_CORS_ALLOWED_ORIGINS=https://" + cfg.DemoHost,
|
||||
},
|
||||
Labels: copyLabels(labels, "core"),
|
||||
Memory: cfg.CoreMemoryBytes,
|
||||
Net: cfg.SessionsNetwork,
|
||||
Alias: coreName,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("spawn core: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DockerClient) runContainer(ctx context.Context, s runSpec) error {
|
||||
// Pull best-effort : si l'image est deja locale, ContainerCreate la reprendra.
|
||||
_ = d.pullImage(ctx, s.Image)
|
||||
|
||||
spec := containerSpec{
|
||||
Image: s.Image,
|
||||
Env: s.Env,
|
||||
Labels: s.Labels,
|
||||
HostConfig: hostConfig{
|
||||
Memory: s.Memory,
|
||||
NanoCPUs: 1_000_000_000, // 1 vCPU par conteneur
|
||||
PidsLimit: 200, // anti fork-bomb
|
||||
Tmpfs: s.Tmpfs,
|
||||
SecurityOpt: []string{"no-new-privileges:true"},
|
||||
RestartPolicy: restartPolicy{Name: "no"},
|
||||
},
|
||||
NetworkingConfig: networkingConfig{
|
||||
EndpointsConfig: map[string]endpointSettings{
|
||||
s.Net: {Aliases: []string{s.Alias}},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
createResp, err := d.do(ctx, "POST", "/containers/create?name="+url.QueryEscape(s.Name), body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", s.Name, err)
|
||||
}
|
||||
var created struct {
|
||||
ID string `json:"Id"`
|
||||
}
|
||||
if err := json.Unmarshal(createResp, &created); err != nil {
|
||||
return fmt.Errorf("parse create %s: %w", s.Name, err)
|
||||
}
|
||||
if _, err := d.do(ctx, "POST", "/containers/"+created.ID+"/start", nil); err != nil {
|
||||
return fmt.Errorf("start %s: %w", s.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pullImage drain le flux de progression. Erreur silencieuse : si le pull
|
||||
// echoue (registre prive sans auth, image deja locale), runContainer aura un
|
||||
// retour clair via ContainerCreate.
|
||||
func (d *DockerClient) pullImage(ctx context.Context, img string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST",
|
||||
d.baseURL+"/images/create?fromImage="+url.QueryEscape(img), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := d.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("pull %s: status %d", img, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WaitReady poll l'endpoint /api/config du core jusqu'a 200 ou timeout.
|
||||
func (d *DockerClient) WaitReady(ctx context.Context, sessionID string, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
target := "http://demo-" + sessionID + "-core:8080/api/config"
|
||||
c := &http.Client{Timeout: 2 * time.Second}
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := c.Get(target)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// KillTrio supprime tous les conteneurs labellises demo-session=<id>.
|
||||
func (d *DockerClient) KillTrio(ctx context.Context, sessionID string) error {
|
||||
containers, err := d.listContainersWithLabel(ctx, "demo-session="+sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range containers {
|
||||
_, _ = d.do(ctx, "DELETE", "/containers/"+c.ID+"?force=true", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSessionIDs : utilise au boot pour retrouver les conteneurs orphelins.
|
||||
func (d *DockerClient) ListSessionIDs(ctx context.Context) ([]string, error) {
|
||||
containers, err := d.listContainersWithLabel(ctx, "demo-session")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range containers {
|
||||
if v, ok := c.Labels["demo-session"]; ok && v != "" {
|
||||
seen[v] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for id := range seen {
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type containerInfo struct {
|
||||
ID string `json:"Id"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
}
|
||||
|
||||
func (d *DockerClient) listContainersWithLabel(ctx context.Context, label string) ([]containerInfo, error) {
|
||||
filters := map[string][]string{"label": {label}}
|
||||
filtersJSON, _ := json.Marshal(filters)
|
||||
q := url.Values{}
|
||||
q.Set("all", "true")
|
||||
q.Set("filters", string(filtersJSON))
|
||||
body, err := d.do(ctx, "GET", "/containers/json?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var list []containerInfo
|
||||
if err := json.Unmarshal(body, &list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// do envoie une requete et renvoie le body. Une reponse 4xx/5xx est convertie
|
||||
// en erreur avec le contenu pour faciliter le debug.
|
||||
func (d *DockerClient) do(ctx context.Context, method, path string, body []byte) ([]byte, error) {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
rdr = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, d.baseURL+path, rdr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := d.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("%s %s: HTTP %d %s", method, path, resp.StatusCode, out)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func copyLabels(base map[string]string, role string) map[string]string {
|
||||
out := make(map[string]string, len(base)+1)
|
||||
for k, v := range base {
|
||||
out[k] = v
|
||||
}
|
||||
out["demo-role"] = role
|
||||
return out
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
8
demo/orchestrator/go.mod
Normal file
8
demo/orchestrator/go.mod
Normal file
@@ -0,0 +1,8 @@
|
||||
module github.com/loremind/demo-orchestrator
|
||||
|
||||
go 1.23
|
||||
|
||||
// Aucune dependance externe : on parle a Docker Engine en HTTP brut
|
||||
// (cf. docker.go) plutot que d'utiliser github.com/docker/docker, dont le
|
||||
// graphe transitif est instable d'une version a l'autre (sockets.DialPipe,
|
||||
// errors.As/Is, otelhttp...).
|
||||
231
demo/orchestrator/main.go
Normal file
231
demo/orchestrator/main.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const cookieName = "loremind-demo-session"
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig()
|
||||
|
||||
docker, err := newDockerClient()
|
||||
if err != nil {
|
||||
log.Fatalf("docker init: %v", err)
|
||||
}
|
||||
|
||||
mgr := newManager(docker, cfg)
|
||||
limiter := newRateLimiter(cfg.RateLimitWindow)
|
||||
|
||||
// Nettoyage des sessions residuelles au boot (redemarrage orchestrateur).
|
||||
cleanCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
mgr.CleanupOrphans(cleanCtx)
|
||||
cancel()
|
||||
|
||||
go mgr.RunGC(context.Background())
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/_demo/ready", readyHandler(mgr))
|
||||
mux.HandleFunc("/api/", apiHandler(mgr, cfg))
|
||||
mux.HandleFunc("/", rootHandler(mgr, limiter, cfg))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":80",
|
||||
Handler: mux,
|
||||
// Timeouts anti-slowloris. WriteTimeout laisse de la marge pour le
|
||||
// streaming SSE (ai/chat/stream) qui peut durer plusieurs minutes.
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 10 * time.Minute,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
// Headers max : 1 Mo (defaut Go), suffisant.
|
||||
}
|
||||
|
||||
log.Printf("orchestrator listening on :80 (max sessions=%d, ttl=%s, rate window=%s)",
|
||||
cfg.MaxSessions, cfg.SessionTTL, cfg.RateLimitWindow)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
log.Fatalf("http server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// rootHandler gere toutes les routes non-API : sert l'Angular statique si le
|
||||
// visiteur a deja une session prete, sinon cree une session (sous rate limit)
|
||||
// et renvoie la page de preparation.
|
||||
func rootHandler(mgr *Manager, limiter *rateLimiter, cfg *Config) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := currentSession(r, mgr)
|
||||
|
||||
// Visiteur connu et session prete -> sert l'app normalement.
|
||||
if sess != nil && sess.Status == StatusReady {
|
||||
serveStatic(w, r, cfg.StaticDir)
|
||||
return
|
||||
}
|
||||
|
||||
// On ne spawn qu'a la navigation initiale (GET d'un document HTML).
|
||||
// Les assets secondaires (JS/CSS/favicon) ne doivent pas declencher
|
||||
// de nouvelle session.
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "No session", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !acceptsHTML(r) {
|
||||
http.Error(w, "No session", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Session inexistante (ou expiree) -> en creer une, sous rate limit.
|
||||
if sess == nil {
|
||||
ip := clientIP(r)
|
||||
if !limiter.Allow(ip) {
|
||||
http.Error(w, "Trop de tentatives. Merci d'attendre "+
|
||||
strconv.Itoa(int(cfg.RateLimitWindow.Seconds()))+"s.",
|
||||
http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
newSess, err := mgr.Create(r.Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrCapacity) {
|
||||
http.Error(w, "La demo est pleine (max "+
|
||||
strconv.Itoa(cfg.MaxSessions)+
|
||||
" sessions simultanees). Merci de reessayer plus tard.",
|
||||
http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Impossible de creer la session : "+err.Error(),
|
||||
http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sess = newSess
|
||||
setCookie(w, sess.ID, cfg.SessionTTL)
|
||||
}
|
||||
|
||||
servePreparingPage(w, cfg.PreparingPage)
|
||||
}
|
||||
}
|
||||
|
||||
// apiHandler proxifie /api/* vers le core de la session.
|
||||
// Bride la taille des bodies a MaxBodyBytes pour limiter les DoS memoire.
|
||||
func apiHandler(mgr *Manager, cfg *Config) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := currentSession(r, mgr)
|
||||
if sess == nil {
|
||||
http.Error(w, "No session", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if sess.Status != StatusReady {
|
||||
http.Error(w, "Session not ready", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if r.Body != nil {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodyBytes)
|
||||
}
|
||||
proxy := sessionProxy(sess)
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// sessionProxy renvoie (et cree si besoin) un reverse proxy cache dans la
|
||||
// session via sync.Once : garantit une seule creation meme sous requetes
|
||||
// concurrentes, sans mutex explicite.
|
||||
func sessionProxy(sess *Session) *httputil.ReverseProxy {
|
||||
sess.proxyOnce.Do(func() {
|
||||
target, _ := url.Parse("http://" + sess.CoreHost + ":8080")
|
||||
p := httputil.NewSingleHostReverseProxy(target)
|
||||
p.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
log.Printf("proxy error session=%s: %v", sess.ID, err)
|
||||
http.Error(w, "Upstream error", http.StatusBadGateway)
|
||||
}
|
||||
sess.proxy = p
|
||||
})
|
||||
return sess.proxy.(*httputil.ReverseProxy)
|
||||
}
|
||||
|
||||
// readyHandler renvoie l'etat de la session en JSON pour le polling client.
|
||||
// N'expose aucun ID de session ni d'information sur les autres sessions.
|
||||
func readyHandler(mgr *Manager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := currentSession(r, mgr)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if sess == nil {
|
||||
json.NewEncoder(w).Encode(map[string]any{"status": "none"})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": string(sess.Status),
|
||||
"error": sess.Err,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// currentSession lit le cookie et retrouve la session en memoire.
|
||||
// Si le cookie pointe vers une session disparue (redemarrage orchestrateur ou
|
||||
// TTL expire), retourne nil -> le handler traitera comme un nouveau visiteur.
|
||||
func currentSession(r *http.Request, mgr *Manager) *Session {
|
||||
c, err := r.Cookie(cookieName)
|
||||
if err != nil || c.Value == "" {
|
||||
return nil
|
||||
}
|
||||
return mgr.Get(c.Value)
|
||||
}
|
||||
|
||||
func setCookie(w http.ResponseWriter, id string, ttl time.Duration) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: id,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true, // Traefik termine le TLS ; le browser ne doit envoyer ce cookie qu'en HTTPS.
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(ttl.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// serveStatic sert les fichiers de l'Angular build avec fallback sur index.html
|
||||
// pour que le routeur cote client fonctionne (SPA).
|
||||
// Le check HasPrefix apres Join + Clean empeche les path traversals (..).
|
||||
func serveStatic(w http.ResponseWriter, r *http.Request, dir string) {
|
||||
reqPath := r.URL.Path
|
||||
if reqPath == "/" || reqPath == "" {
|
||||
reqPath = "/index.html"
|
||||
}
|
||||
fullPath := filepath.Join(dir, filepath.Clean(reqPath))
|
||||
if !strings.HasPrefix(fullPath, dir) {
|
||||
http.Error(w, "bad path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
|
||||
http.ServeFile(w, r, fullPath)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, filepath.Join(dir, "index.html"))
|
||||
}
|
||||
|
||||
// servePreparingPage sert la page de chargement statique. Le cookie vient
|
||||
// d'etre pose, le JS de la page utilisera sessionId implicitement via le
|
||||
// cookie pour poller /_demo/ready.
|
||||
func servePreparingPage(w http.ResponseWriter, path string) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
http.Error(w, "Preparing page not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func acceptsHTML(r *http.Request) bool {
|
||||
accept := r.Header.Get("Accept")
|
||||
return strings.Contains(accept, "text/html") || accept == "" || accept == "*/*"
|
||||
}
|
||||
127
demo/orchestrator/preparing.html
Normal file
127
demo/orchestrator/preparing.html
Normal file
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LoreMind — Demo en preparation</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #1a1625;
|
||||
color: #e4def5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
}
|
||||
.card {
|
||||
max-width: 440px;
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
.logo {
|
||||
font-size: 2rem;
|
||||
color: #b794f4;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.subtitle {
|
||||
letter-spacing: 0.2em;
|
||||
font-size: 0.75rem;
|
||||
color: #9880c4;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 500;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
p {
|
||||
color: #aaa0c5;
|
||||
line-height: 1.6;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 1.5rem auto 0;
|
||||
border: 3px solid rgba(183, 148, 244, 0.2);
|
||||
border-top-color: #b794f4;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.error {
|
||||
display: none;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #fca5a5;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.error.visible { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<div class="logo">✦ LoreMind</div>
|
||||
<div class="subtitle">THE DIGITAL CODEX</div>
|
||||
<h1>Preparation de votre demo…</h1>
|
||||
<p>
|
||||
Nous initialisons une instance isolee rien que pour vous.
|
||||
Cela prend generalement 20 a 40 secondes.
|
||||
</p>
|
||||
<p style="font-size: 0.8rem; color: #7d6ba0; margin-top: 1rem;">
|
||||
Votre session sera automatiquement reinitialisee au bout de 20 minutes.
|
||||
</p>
|
||||
<div class="spinner"></div>
|
||||
<div id="err" class="error"></div>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
var errBox = document.getElementById('err');
|
||||
var attempts = 0;
|
||||
var maxAttempts = 90; // 90 * 2s = 3 min max
|
||||
|
||||
function poll() {
|
||||
attempts++;
|
||||
fetch('/_demo/ready', { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.status === 'ready') {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
if (data.status === 'failed') {
|
||||
errBox.textContent = 'Echec du demarrage : ' + (data.error || 'raison inconnue') + '. Rechargez la page pour reessayer.';
|
||||
errBox.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
if (attempts >= maxAttempts) {
|
||||
errBox.textContent = 'Timeout. Rechargez la page pour reessayer.';
|
||||
errBox.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, 2000);
|
||||
})
|
||||
.catch(function () {
|
||||
if (attempts >= maxAttempts) {
|
||||
errBox.textContent = 'Connexion perdue. Rechargez la page pour reessayer.';
|
||||
errBox.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, 2000);
|
||||
});
|
||||
}
|
||||
poll();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
79
demo/orchestrator/ratelimit.go
Normal file
79
demo/orchestrator/ratelimit.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rateLimiter autorise au plus une action par IP dans une fenetre glissante.
|
||||
// Pas de token bucket : pour un endpoint de creation de session, "1 par
|
||||
// fenetre" est largement suffisant et plus simple a raisonner.
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
lastSeen map[string]time.Time
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
func newRateLimiter(window time.Duration) *rateLimiter {
|
||||
rl := &rateLimiter{
|
||||
lastSeen: make(map[string]time.Time),
|
||||
window: window,
|
||||
}
|
||||
go rl.cleanupLoop()
|
||||
return rl
|
||||
}
|
||||
|
||||
// Allow renvoie true si l'IP n'a pas deja declenche d'action dans la fenetre.
|
||||
// Sur true, l'horloge de l'IP est reinitialisee.
|
||||
func (rl *rateLimiter) Allow(ip string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
now := time.Now()
|
||||
if last, ok := rl.lastSeen[ip]; ok && now.Sub(last) < rl.window {
|
||||
return false
|
||||
}
|
||||
rl.lastSeen[ip] = now
|
||||
return true
|
||||
}
|
||||
|
||||
// cleanupLoop purge les entrees plus anciennes que 2x la fenetre pour eviter
|
||||
// la croissance non bornee de la map sous trafic varie.
|
||||
func (rl *rateLimiter) cleanupLoop() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
cutoff := time.Now().Add(-2 * rl.window)
|
||||
rl.mu.Lock()
|
||||
for ip, t := range rl.lastSeen {
|
||||
if t.Before(cutoff) {
|
||||
delete(rl.lastSeen, ip)
|
||||
}
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// clientIP extrait l'IP reelle du visiteur en tenant compte du setup reverse-proxy.
|
||||
// Ordre de priorite :
|
||||
// 1. CF-Connecting-IP : defini par Cloudflare sur la base de SA propre vue du
|
||||
// peer TCP, non-forgeable par le client, ecrase toute valeur entrante.
|
||||
// 2. X-Forwarded-For, derniere entree : quand seul Traefik est en front (pas
|
||||
// de Cloudflare), Traefik append l'IP qu'il observe. Prendre la premiere
|
||||
// serait une faille (header forgeable).
|
||||
// 3. RemoteAddr : fallback si aucun header de proxy n'est present.
|
||||
func clientIP(r *http.Request) string {
|
||||
if cfIP := r.Header.Get("CF-Connecting-IP"); cfIP != "" {
|
||||
return strings.TrimSpace(cfIP)
|
||||
}
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(parts[len(parts)-1])
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
177
demo/orchestrator/sessions.go
Normal file
177
demo/orchestrator/sessions.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionStatus reflete l'etat du cycle de vie d'un trio de session.
|
||||
type SessionStatus string
|
||||
|
||||
const (
|
||||
StatusStarting SessionStatus = "starting"
|
||||
StatusReady SessionStatus = "ready"
|
||||
StatusFailed SessionStatus = "failed"
|
||||
)
|
||||
|
||||
// Session represente une demo isolee pour un visiteur.
|
||||
// CoreHost est le hostname Docker interne du conteneur core de cette session
|
||||
// (ex: "demo-abc123-core"), vers lequel l'orchestrateur proxifie les /api/*.
|
||||
type Session struct {
|
||||
ID string
|
||||
CreatedAt time.Time
|
||||
Status SessionStatus
|
||||
CoreHost string
|
||||
Err string
|
||||
// proxy et proxyOnce : reverse-proxy cache, cree au plus une fois via
|
||||
// sync.Once (evite la race entre deux requetes concurrentes sur la meme
|
||||
// session). proxy est typee any pour ne pas contraindre sessions.go a
|
||||
// importer net/http/httputil.
|
||||
proxy any
|
||||
proxyOnce sync.Once
|
||||
}
|
||||
|
||||
// Manager gere le cycle de vie des sessions (creation, acces, cleanup).
|
||||
// Thread-safe : le mutex protege la map contre les acces concurrents (HTTP
|
||||
// handlers + goroutine de GC).
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*Session
|
||||
docker *DockerClient
|
||||
cfg *Config
|
||||
}
|
||||
|
||||
func newManager(docker *DockerClient, cfg *Config) *Manager {
|
||||
return &Manager{
|
||||
sessions: make(map[string]*Session),
|
||||
docker: docker,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// ErrCapacity est retournee quand MAX_SESSIONS est atteint.
|
||||
var ErrCapacity = errors.New("demo at capacity")
|
||||
|
||||
// Create reserve un slot et lance le spawn des conteneurs en arriere-plan.
|
||||
// Retourne immediatement avec Status=starting. L'etat bascule a "ready" quand
|
||||
// les conteneurs sont up et que core repond a /api/config.
|
||||
func (m *Manager) Create(ctx context.Context) (*Session, error) {
|
||||
m.mu.Lock()
|
||||
if len(m.sessions) >= m.cfg.MaxSessions {
|
||||
m.mu.Unlock()
|
||||
return nil, ErrCapacity
|
||||
}
|
||||
id := newShortID()
|
||||
sess := &Session{
|
||||
ID: id,
|
||||
CreatedAt: time.Now(),
|
||||
Status: StatusStarting,
|
||||
CoreHost: "demo-" + id + "-core",
|
||||
}
|
||||
m.sessions[id] = sess
|
||||
m.mu.Unlock()
|
||||
|
||||
// Spawn asynchrone : l'utilisateur voit immediatement la page "preparation".
|
||||
go func() {
|
||||
spawnCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if err := m.docker.SpawnTrio(spawnCtx, id, m.cfg); err != nil {
|
||||
log.Printf("session %s spawn failed: %v", id, err)
|
||||
m.mu.Lock()
|
||||
sess.Status = StatusFailed
|
||||
sess.Err = err.Error()
|
||||
m.mu.Unlock()
|
||||
// Nettoyage best-effort des conteneurs partiellement crees.
|
||||
_ = m.docker.KillTrio(context.Background(), id)
|
||||
return
|
||||
}
|
||||
// Attente que core reponde (sinon proxy retourne 502 aux premieres requetes).
|
||||
if m.docker.WaitReady(spawnCtx, id, 90*time.Second) {
|
||||
m.mu.Lock()
|
||||
sess.Status = StatusReady
|
||||
m.mu.Unlock()
|
||||
log.Printf("session %s ready", id)
|
||||
} else {
|
||||
log.Printf("session %s never became ready", id)
|
||||
m.mu.Lock()
|
||||
sess.Status = StatusFailed
|
||||
sess.Err = "timeout waiting for core"
|
||||
m.mu.Unlock()
|
||||
_ = m.docker.KillTrio(context.Background(), id)
|
||||
}
|
||||
}()
|
||||
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// Get renvoie la session associee a un ID, ou nil si elle n'existe plus.
|
||||
func (m *Manager) Get(id string) *Session {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.sessions[id]
|
||||
}
|
||||
|
||||
// RunGC boucle toutes les minutes pour supprimer les sessions expirees.
|
||||
// A lancer en goroutine au demarrage.
|
||||
func (m *Manager) RunGC(ctx context.Context) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.gcOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) gcOnce() {
|
||||
cutoff := time.Now().Add(-m.cfg.SessionTTL)
|
||||
m.mu.Lock()
|
||||
var expired []string
|
||||
for id, s := range m.sessions {
|
||||
if s.CreatedAt.Before(cutoff) {
|
||||
expired = append(expired, id)
|
||||
}
|
||||
}
|
||||
for _, id := range expired {
|
||||
delete(m.sessions, id)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, id := range expired {
|
||||
log.Printf("session %s expired, killing containers", id)
|
||||
if err := m.docker.KillTrio(context.Background(), id); err != nil {
|
||||
log.Printf("kill %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupOrphans tue les conteneurs demo-* qui ne correspondent a aucune
|
||||
// session en memoire. Appele au demarrage pour gerer un redemarrage brutal.
|
||||
func (m *Manager) CleanupOrphans(ctx context.Context) {
|
||||
ids, err := m.docker.ListSessionIDs(ctx)
|
||||
if err != nil {
|
||||
log.Printf("list orphans: %v", err)
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
log.Printf("cleaning orphan session %s", id)
|
||||
_ = m.docker.KillTrio(ctx, id)
|
||||
}
|
||||
}
|
||||
|
||||
// newShortID genere un identifiant hexadecimal de 32 caracteres (128 bits).
|
||||
// 128 bits d'entropie rendent les collisions et le brute-force statistiquement
|
||||
// impossibles, meme si un attaquant pouvait tenter des millions de cookies.
|
||||
func newShortID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
17
docker-compose.e2e.yml
Normal file
17
docker-compose.e2e.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
# Override pour la CI E2E : build les images depuis les sources au lieu de les puller.
|
||||
# Usage : docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build
|
||||
services:
|
||||
core:
|
||||
build:
|
||||
context: ./core
|
||||
image: loremind-core:e2e
|
||||
|
||||
brain:
|
||||
build:
|
||||
context: ./brain
|
||||
image: loremind-brain:e2e
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ./web
|
||||
image: loremind-web:e2e
|
||||
@@ -60,7 +60,8 @@
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"buildTarget": "web:build"
|
||||
"buildTarget": "web:build",
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
317
web/e2e/fixtures/api.ts
Normal file
317
web/e2e/fixtures/api.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { APIRequestContext, expect } from '@playwright/test';
|
||||
|
||||
export interface SeededLore {
|
||||
id: string;
|
||||
name: string;
|
||||
rootFolderId: string;
|
||||
rootFolderName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed un Lore + un dossier racine via l'API backend.
|
||||
* Les noms sont uniques (timestamp + random) pour éviter les collisions en parallèle.
|
||||
*/
|
||||
export async function seedLoreWithFolder(request: APIRequestContext): Promise<SeededLore> {
|
||||
const suffix = `${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
const loreName = `E2E Lore ${suffix}`;
|
||||
const folderName = `E2E Folder ${suffix}`;
|
||||
|
||||
const loreRes = await request.post('/api/lores', {
|
||||
data: { name: loreName, description: 'Seeded by Playwright' },
|
||||
});
|
||||
expect(loreRes.ok(), `POST /api/lores -> ${loreRes.status()}`).toBeTruthy();
|
||||
const lore = await loreRes.json();
|
||||
|
||||
const folderRes = await request.post('/api/lore-nodes', {
|
||||
data: { loreId: lore.id, name: folderName, icon: 'folder', description: '' },
|
||||
});
|
||||
expect(folderRes.ok(), `POST /api/lore-nodes -> ${folderRes.status()}`).toBeTruthy();
|
||||
const folder = await folderRes.json();
|
||||
|
||||
return { id: lore.id, name: loreName, rootFolderId: folder.id, rootFolderName: folderName };
|
||||
}
|
||||
|
||||
/** Cleanup best-effort — n'échoue pas si déjà supprimé. */
|
||||
export async function deleteLore(request: APIRequestContext, loreId: string): Promise<void> {
|
||||
await request.delete(`/api/lores/${loreId}`).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function getLoreById(
|
||||
request: APIRequestContext,
|
||||
loreId: string,
|
||||
): Promise<{ id: string; name: string; description: string }> {
|
||||
const res = await request.get(`/api/lores/${loreId}`);
|
||||
expect(res.ok(), `GET /api/lores/${loreId} -> ${res.status()}`).toBeTruthy();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getArcsForCampaign(
|
||||
request: APIRequestContext,
|
||||
campaignId: string,
|
||||
): Promise<Array<{ id: string; name: string; campaignId: string }>> {
|
||||
const res = await request.get(`/api/arcs?campaignId=${campaignId}`);
|
||||
expect(res.ok(), `GET /api/arcs -> ${res.status()}`).toBeTruthy();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getChaptersForArc(
|
||||
request: APIRequestContext,
|
||||
arcId: string,
|
||||
): Promise<Array<{ id: string; name: string; arcId: string }>> {
|
||||
const res = await request.get(`/api/chapters?arcId=${arcId}`);
|
||||
expect(res.ok(), `GET /api/chapters -> ${res.status()}`).toBeTruthy();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getScenesForChapter(
|
||||
request: APIRequestContext,
|
||||
chapterId: string,
|
||||
): Promise<Array<{ id: string; name: string; chapterId: string }>> {
|
||||
const res = await request.get(`/api/scenes?chapterId=${chapterId}`);
|
||||
expect(res.ok(), `GET /api/scenes -> ${res.status()}`).toBeTruthy();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getTemplatesForLore(
|
||||
request: APIRequestContext,
|
||||
loreId: string,
|
||||
): Promise<Array<{ id: string; name: string }>> {
|
||||
const res = await request.get(`/api/templates?loreId=${loreId}`);
|
||||
expect(res.ok(), `GET /api/templates -> ${res.status()}`).toBeTruthy();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface SeededTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function seedTemplate(
|
||||
request: APIRequestContext,
|
||||
opts: { loreId: string; defaultNodeId: string; name?: string; fieldNames?: string[] },
|
||||
): Promise<SeededTemplate> {
|
||||
const templateName = opts.name ?? `E2E Template ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
const fields = (opts.fieldNames ?? ['Nom', 'Description']).map((name) => ({ name, type: 'TEXT' }));
|
||||
|
||||
const res = await request.post('/api/templates', {
|
||||
data: {
|
||||
loreId: opts.loreId,
|
||||
name: templateName,
|
||||
description: 'Seeded by Playwright',
|
||||
defaultNodeId: opts.defaultNodeId,
|
||||
fields,
|
||||
},
|
||||
});
|
||||
expect(res.ok(), `POST /api/templates -> ${res.status()}`).toBeTruthy();
|
||||
const tpl = await res.json();
|
||||
return { id: tpl.id, name: templateName };
|
||||
}
|
||||
|
||||
export async function deleteCampaign(request: APIRequestContext, campaignId: string): Promise<void> {
|
||||
await request.delete(`/api/campaigns/${campaignId}`).catch(() => undefined);
|
||||
}
|
||||
|
||||
export interface SeededCampaign {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function seedCampaign(
|
||||
request: APIRequestContext,
|
||||
opts: { name?: string; loreId?: string | null; playerCount?: number } = {},
|
||||
): Promise<SeededCampaign> {
|
||||
const name = opts.name ?? `E2E Campaign ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
const res = await request.post('/api/campaigns', {
|
||||
data: {
|
||||
name,
|
||||
description: 'Seeded by Playwright',
|
||||
playerCount: opts.playerCount ?? 4,
|
||||
loreId: opts.loreId ?? null,
|
||||
gameSystemId: null,
|
||||
},
|
||||
});
|
||||
expect(res.ok(), `POST /api/campaigns -> ${res.status()}`).toBeTruthy();
|
||||
const c = await res.json();
|
||||
return { id: c.id, name };
|
||||
}
|
||||
|
||||
export interface SeededArc {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function seedArc(
|
||||
request: APIRequestContext,
|
||||
opts: { campaignId: string; name?: string; order?: number },
|
||||
): Promise<SeededArc> {
|
||||
const name = opts.name ?? `E2E Arc ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
const res = await request.post('/api/arcs', {
|
||||
data: {
|
||||
campaignId: opts.campaignId,
|
||||
name,
|
||||
description: '',
|
||||
order: opts.order ?? 1,
|
||||
},
|
||||
});
|
||||
expect(res.ok(), `POST /api/arcs -> ${res.status()}`).toBeTruthy();
|
||||
const a = await res.json();
|
||||
return { id: a.id, name };
|
||||
}
|
||||
|
||||
export interface SeededChapter {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function seedChapter(
|
||||
request: APIRequestContext,
|
||||
opts: { arcId: string; name?: string; order?: number },
|
||||
): Promise<SeededChapter> {
|
||||
const name = opts.name ?? `E2E Chapter ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
const res = await request.post('/api/chapters', {
|
||||
data: { arcId: opts.arcId, name, description: '', order: opts.order ?? 1 },
|
||||
});
|
||||
expect(res.ok(), `POST /api/chapters -> ${res.status()}`).toBeTruthy();
|
||||
const c = await res.json();
|
||||
return { id: c.id, name };
|
||||
}
|
||||
|
||||
export async function getChapterById(
|
||||
request: APIRequestContext,
|
||||
chapterId: string,
|
||||
): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
gmNotes?: string | null;
|
||||
playerObjectives?: string | null;
|
||||
narrativeStakes?: string | null;
|
||||
}> {
|
||||
const res = await request.get(`/api/chapters/${chapterId}`);
|
||||
expect(res.ok(), `GET /api/chapters/${chapterId} -> ${res.status()}`).toBeTruthy();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface SeededScene {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function seedScene(
|
||||
request: APIRequestContext,
|
||||
opts: { chapterId: string; name?: string; order?: number },
|
||||
): Promise<SeededScene> {
|
||||
const name = opts.name ?? `E2E Scene ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
const res = await request.post('/api/scenes', {
|
||||
data: { chapterId: opts.chapterId, name, description: '', order: opts.order ?? 1 },
|
||||
});
|
||||
expect(res.ok(), `POST /api/scenes -> ${res.status()}`).toBeTruthy();
|
||||
const s = await res.json();
|
||||
return { id: s.id, name };
|
||||
}
|
||||
|
||||
export async function getSceneById(
|
||||
request: APIRequestContext,
|
||||
sceneId: string,
|
||||
): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
location?: string | null;
|
||||
timing?: string | null;
|
||||
atmosphere?: string | null;
|
||||
playerNarration?: string | null;
|
||||
gmSecretNotes?: string | null;
|
||||
choicesConsequences?: string | null;
|
||||
combatDifficulty?: string | null;
|
||||
enemies?: string | null;
|
||||
branches?: Array<{ label: string; targetSceneId: string; condition?: string }>;
|
||||
}> {
|
||||
const res = await request.get(`/api/scenes/${sceneId}`);
|
||||
expect(res.ok(), `GET /api/scenes/${sceneId} -> ${res.status()}`).toBeTruthy();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getArcById(
|
||||
request: APIRequestContext,
|
||||
arcId: string,
|
||||
): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
themes?: string | null;
|
||||
stakes?: string | null;
|
||||
gmNotes?: string | null;
|
||||
rewards?: string | null;
|
||||
resolution?: string | null;
|
||||
}> {
|
||||
const res = await request.get(`/api/arcs/${arcId}`);
|
||||
expect(res.ok(), `GET /api/arcs/${arcId} -> ${res.status()}`).toBeTruthy();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getCampaigns(
|
||||
request: APIRequestContext,
|
||||
): Promise<Array<{ id: string; name: string; loreId: string | null }>> {
|
||||
const res = await request.get('/api/campaigns');
|
||||
expect(res.ok(), `GET /api/campaigns -> ${res.status()}`).toBeTruthy();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getPagesForLore(
|
||||
request: APIRequestContext,
|
||||
loreId: string,
|
||||
): Promise<Array<{ id: string; title: string; nodeId: string; templateId: string }>> {
|
||||
const res = await request.get(`/api/pages?loreId=${loreId}`);
|
||||
expect(res.ok(), `GET /api/pages -> ${res.status()}`).toBeTruthy();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface SeededPage {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export async function seedPage(
|
||||
request: APIRequestContext,
|
||||
opts: { loreId: string; nodeId: string; templateId: string; title?: string },
|
||||
): Promise<SeededPage> {
|
||||
const title = opts.title ?? `E2E Page ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
const res = await request.post('/api/pages', {
|
||||
data: { loreId: opts.loreId, nodeId: opts.nodeId, templateId: opts.templateId, title },
|
||||
});
|
||||
expect(res.ok(), `POST /api/pages -> ${res.status()}`).toBeTruthy();
|
||||
const page = await res.json();
|
||||
return { id: page.id, title };
|
||||
}
|
||||
|
||||
export async function getPageById(
|
||||
request: APIRequestContext,
|
||||
pageId: string,
|
||||
): Promise<{
|
||||
id: string;
|
||||
title: string;
|
||||
nodeId: string;
|
||||
values?: Record<string, string>;
|
||||
tags?: string[];
|
||||
notes?: string;
|
||||
}> {
|
||||
const res = await request.get(`/api/pages/${pageId}`);
|
||||
expect(res.ok(), `GET /api/pages/${pageId} -> ${res.status()}`).toBeTruthy();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getTemplateById(
|
||||
request: APIRequestContext,
|
||||
templateId: string,
|
||||
): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
defaultNodeId?: string | null;
|
||||
fields: Array<{ name: string; type: string }>;
|
||||
}> {
|
||||
const res = await request.get(`/api/templates/${templateId}`);
|
||||
expect(res.ok(), `GET /api/templates/${templateId} -> ${res.status()}`).toBeTruthy();
|
||||
return res.json();
|
||||
}
|
||||
50
web/e2e/tests/campaign/arc-create.spec.ts
Normal file
50
web/e2e/tests/campaign/arc-create.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedCampaign,
|
||||
deleteCampaign,
|
||||
getArcById,
|
||||
type SeededCampaign,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('Arc creation', () => {
|
||||
let campaign: SeededCampaign;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
campaign = await seedCampaign(request);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||
});
|
||||
|
||||
test('creates an arc and redirects to its view', async ({ page, request }) => {
|
||||
const arcName = `Arc ${Date.now()}`;
|
||||
const description = 'Synopsis test';
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}/arcs/create`);
|
||||
await expect(page.getByRole('heading', { name: /Créer un nouvel arc/i })).toBeVisible();
|
||||
|
||||
await page.getByLabel(/Nom de l'arc/i).fill(arcName);
|
||||
await page.getByLabel(/Description/i).fill(description);
|
||||
|
||||
await page.getByRole('button', { name: /^Créer l'arc$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/\\d+$`));
|
||||
|
||||
const createdId = page.url().match(/\/arcs\/(\d+)$/)?.[1];
|
||||
expect(createdId).toBeTruthy();
|
||||
const persisted = await getArcById(request, createdId!);
|
||||
expect(persisted.name).toBe(arcName);
|
||||
expect(persisted.description).toBe(description);
|
||||
});
|
||||
|
||||
test('submit is disabled when name is empty', async ({ page }) => {
|
||||
await page.goto(`/campaigns/${campaign.id}/arcs/create`);
|
||||
|
||||
const submit = page.getByRole('button', { name: /^Créer l'arc$/i });
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
await page.getByLabel(/Nom de l'arc/i).fill('Quelque chose');
|
||||
await expect(submit).toBeEnabled();
|
||||
});
|
||||
});
|
||||
49
web/e2e/tests/campaign/arc-delete.spec.ts
Normal file
49
web/e2e/tests/campaign/arc-delete.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedCampaign,
|
||||
seedArc,
|
||||
deleteCampaign,
|
||||
type SeededCampaign,
|
||||
type SeededArc,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('Arc delete', () => {
|
||||
let campaign: SeededCampaign;
|
||||
let arc: SeededArc;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
campaign = await seedCampaign(request);
|
||||
arc = await seedArc(request, { campaignId: campaign.id });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||
});
|
||||
|
||||
test('deletes an arc after accepting confirm and redirects to the campaign', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||
|
||||
const res = await request.get(`/api/arcs/${arc.id}`);
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('keeps the arc when confirm is dismissed', async ({ page, request }) => {
|
||||
page.on('dialog', (dialog) => dialog.dismiss());
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/edit$`));
|
||||
|
||||
const res = await request.get(`/api/arcs/${arc.id}`);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
76
web/e2e/tests/campaign/arc-edit.spec.ts
Normal file
76
web/e2e/tests/campaign/arc-edit.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedCampaign,
|
||||
seedArc,
|
||||
deleteCampaign,
|
||||
getArcById,
|
||||
type SeededCampaign,
|
||||
type SeededArc,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('Arc edit', () => {
|
||||
let campaign: SeededCampaign;
|
||||
let arc: SeededArc;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
campaign = await seedCampaign(request);
|
||||
arc = await seedArc(request, { campaignId: campaign.id });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||
});
|
||||
|
||||
test('form is prefilled with the arc name', async ({ page }) => {
|
||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||
await expect(page.getByLabel(/Titre de l'arc/i)).toHaveValue(arc.name);
|
||||
});
|
||||
|
||||
test('edits all narrative fields and persists them to API', async ({ page, request }) => {
|
||||
const newName = `${arc.name} renamed`;
|
||||
const values = {
|
||||
description: "Un arc sombre où la trahison s'installe.",
|
||||
themes: 'Trahison, rédemption, dette de sang.',
|
||||
stakes: 'La survie du royaume est en jeu.',
|
||||
gmNotes: 'Révéler le traître en scène 3.',
|
||||
rewards: 'Relique ancienne + alliance avec le clan nordique.',
|
||||
resolution: 'Le héros pardonne au traître ou le tue.',
|
||||
};
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||
|
||||
await page.getByLabel(/Titre de l'arc/i).fill(newName);
|
||||
await page.getByLabel(/Synopsis de l'arc/i).fill(values.description);
|
||||
await page.getByLabel(/Thèmes principaux/i).fill(values.themes);
|
||||
await page.getByLabel(/Enjeux globaux/i).fill(values.stakes);
|
||||
await page.getByLabel(/Notes et planification du MJ/i).fill(values.gmNotes);
|
||||
await page.getByLabel(/Récompenses et progression/i).fill(values.rewards);
|
||||
await page.getByLabel(/Dénouement prévu/i).fill(values.resolution);
|
||||
|
||||
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}$`));
|
||||
|
||||
const persisted = await getArcById(request, arc.id);
|
||||
expect(persisted.name).toBe(newName);
|
||||
expect(persisted.description).toBe(values.description);
|
||||
expect(persisted.themes).toBe(values.themes);
|
||||
expect(persisted.stakes).toBe(values.stakes);
|
||||
expect(persisted.gmNotes).toBe(values.gmNotes);
|
||||
expect(persisted.rewards).toBe(values.rewards);
|
||||
expect(persisted.resolution).toBe(values.resolution);
|
||||
});
|
||||
|
||||
test('save button is disabled when name is empty', async ({ page }) => {
|
||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||
|
||||
const nameField = page.getByLabel(/Titre de l'arc/i);
|
||||
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
|
||||
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
await nameField.fill('');
|
||||
await expect(saveBtn).toBeDisabled();
|
||||
await nameField.fill('Valid');
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
});
|
||||
});
|
||||
96
web/e2e/tests/campaign/campaign-create.spec.ts
Normal file
96
web/e2e/tests/campaign/campaign-create.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedLoreWithFolder,
|
||||
deleteLore,
|
||||
deleteCampaign,
|
||||
getCampaigns,
|
||||
type SeededLore,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('Campaign creation', () => {
|
||||
const createdCampaignIds: string[] = [];
|
||||
let linkedLore: SeededLore;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
linkedLore = await seedLoreWithFolder(request);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
while (createdCampaignIds.length) {
|
||||
await deleteCampaign(request, createdCampaignIds.pop()!);
|
||||
}
|
||||
if (linkedLore?.id) await deleteLore(request, linkedLore.id);
|
||||
});
|
||||
|
||||
test('creates a standalone campaign (no lore, no system) and shows it in the grid', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const campaignName = `Campagne E2E ${Date.now()}`;
|
||||
const description = 'Une campagne créée par les tests automatisés.';
|
||||
|
||||
await page.goto('/campaigns');
|
||||
await expect(page.getByRole('heading', { name: /Vos Campagnes|Campagnes/i })).toBeVisible();
|
||||
|
||||
await page.locator('.campaign-card.card-new').click();
|
||||
|
||||
const modal = page.locator('.modal');
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
await modal.getByLabel(/Nom de la campagne/i).fill(campaignName);
|
||||
await modal.getByLabel(/Description/i).fill(description);
|
||||
await modal.getByLabel(/Nombre de joueurs/i).fill('5');
|
||||
|
||||
await modal.getByRole('button', { name: /^Créer la campagne$/i }).click();
|
||||
|
||||
await expect(modal).not.toBeVisible();
|
||||
|
||||
const newCard = page.locator('.campaign-card', { hasText: campaignName });
|
||||
await expect(newCard).toBeVisible();
|
||||
|
||||
const campaigns = await getCampaigns(request);
|
||||
const created = campaigns.find((c) => c.name === campaignName);
|
||||
expect(created).toBeDefined();
|
||||
expect(created!.loreId).toBeNull();
|
||||
createdCampaignIds.push(created!.id);
|
||||
});
|
||||
|
||||
test('creates a campaign linked to an existing lore', async ({ page, request }) => {
|
||||
const campaignName = `Campagne liée ${Date.now()}`;
|
||||
|
||||
await page.goto('/campaigns');
|
||||
await page.locator('.campaign-card.card-new').click();
|
||||
|
||||
const modal = page.locator('.modal');
|
||||
await modal.getByLabel(/Nom de la campagne/i).fill(campaignName);
|
||||
await modal.getByLabel(/Univers associé/i).selectOption({ label: linkedLore.name });
|
||||
|
||||
await modal.getByRole('button', { name: /^Créer la campagne$/i }).click();
|
||||
await expect(modal).not.toBeVisible();
|
||||
|
||||
const campaigns = await getCampaigns(request);
|
||||
const created = campaigns.find((c) => c.name === campaignName);
|
||||
expect(created).toBeDefined();
|
||||
expect(created!.loreId).toBe(linkedLore.id);
|
||||
createdCampaignIds.push(created!.id);
|
||||
});
|
||||
|
||||
test('submit is disabled without a name and when player count is invalid', async ({ page }) => {
|
||||
await page.goto('/campaigns');
|
||||
await page.locator('.campaign-card.card-new').click();
|
||||
|
||||
const modal = page.locator('.modal');
|
||||
const submit = modal.getByRole('button', { name: /^Créer la campagne$/i });
|
||||
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
await modal.getByLabel(/Nom de la campagne/i).fill('Valid name');
|
||||
await expect(submit).toBeEnabled();
|
||||
|
||||
await modal.getByLabel(/Nombre de joueurs/i).fill('0');
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
await modal.getByLabel(/Nombre de joueurs/i).fill('3');
|
||||
await expect(submit).toBeEnabled();
|
||||
});
|
||||
});
|
||||
41
web/e2e/tests/campaign/campaign-delete.spec.ts
Normal file
41
web/e2e/tests/campaign/campaign-delete.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { seedCampaign, deleteCampaign, type SeededCampaign } from '../../fixtures/api';
|
||||
|
||||
test.describe('Campaign delete', () => {
|
||||
let campaign: SeededCampaign;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
campaign = await seedCampaign(request);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||
});
|
||||
|
||||
test('deletes a campaign after accepting confirm and redirects to the list', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}`);
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/campaigns$/);
|
||||
|
||||
const res = await request.get(`/api/campaigns/${campaign.id}`);
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('keeps the campaign when confirm is dismissed', async ({ page, request }) => {
|
||||
page.on('dialog', (dialog) => dialog.dismiss());
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}`);
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||
|
||||
const res = await request.get(`/api/campaigns/${campaign.id}`);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
54
web/e2e/tests/campaign/chapter-create.spec.ts
Normal file
54
web/e2e/tests/campaign/chapter-create.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedCampaign,
|
||||
seedArc,
|
||||
deleteCampaign,
|
||||
getChapterById,
|
||||
type SeededCampaign,
|
||||
type SeededArc,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('Chapter creation', () => {
|
||||
let campaign: SeededCampaign;
|
||||
let arc: SeededArc;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
campaign = await seedCampaign(request);
|
||||
arc = await seedArc(request, { campaignId: campaign.id });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||
});
|
||||
|
||||
test('creates a chapter and redirects to its view', async ({ page, request }) => {
|
||||
const chapterName = `Chapitre ${Date.now()}`;
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/create`);
|
||||
await expect(page.getByRole('heading', { name: /Créer un nouveau chapitre/i })).toBeVisible();
|
||||
|
||||
await page.getByLabel(/Nom du chapitre/i).fill(chapterName);
|
||||
await page.getByLabel(/Description/i).fill('Synopsis du chapitre');
|
||||
|
||||
await page.getByRole('button', { name: /^Créer le chapitre$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/\\d+$`),
|
||||
);
|
||||
|
||||
const createdId = page.url().match(/\/chapters\/(\d+)$/)?.[1];
|
||||
expect(createdId).toBeTruthy();
|
||||
const persisted = await getChapterById(request, createdId!);
|
||||
expect(persisted.name).toBe(chapterName);
|
||||
});
|
||||
|
||||
test('submit is disabled when name is empty', async ({ page }) => {
|
||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/create`);
|
||||
|
||||
const submit = page.getByRole('button', { name: /^Créer le chapitre$/i });
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
await page.getByLabel(/Nom du chapitre/i).fill('OK');
|
||||
await expect(submit).toBeEnabled();
|
||||
});
|
||||
});
|
||||
72
web/e2e/tests/campaign/chapter-edit.spec.ts
Normal file
72
web/e2e/tests/campaign/chapter-edit.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedCampaign,
|
||||
seedArc,
|
||||
seedChapter,
|
||||
deleteCampaign,
|
||||
getChapterById,
|
||||
type SeededCampaign,
|
||||
type SeededArc,
|
||||
type SeededChapter,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('Chapter edit', () => {
|
||||
let campaign: SeededCampaign;
|
||||
let arc: SeededArc;
|
||||
let chapter: SeededChapter;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
campaign = await seedCampaign(request);
|
||||
arc = await seedArc(request, { campaignId: campaign.id });
|
||||
chapter = await seedChapter(request, { arcId: arc.id });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||
});
|
||||
|
||||
test('edits all chapter fields and persists them to API', async ({ page, request }) => {
|
||||
const newName = `${chapter.name} renamed`;
|
||||
const values = {
|
||||
description: 'Le chapitre ouvre sur un village en proie à la peur.',
|
||||
gmNotes: 'Le maire cache un pacte avec les gobelins.',
|
||||
playerObjectives: "Découvrir la source des disparitions.",
|
||||
narrativeStakes: "La confiance du village est en jeu.",
|
||||
};
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/edit`);
|
||||
|
||||
await expect(page.getByLabel(/Titre du chapitre/i)).toHaveValue(chapter.name);
|
||||
|
||||
await page.getByLabel(/Titre du chapitre/i).fill(newName);
|
||||
await page.getByLabel(/Synopsis du chapitre/i).fill(values.description);
|
||||
await page.getByLabel(/Notes du Maître de Jeu/i).fill(values.gmNotes);
|
||||
await page.getByLabel(/Objectifs des joueurs/i).fill(values.playerObjectives);
|
||||
await page.getByLabel(/Enjeux narratifs/i).fill(values.narrativeStakes);
|
||||
|
||||
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}$`),
|
||||
);
|
||||
|
||||
const persisted = await getChapterById(request, chapter.id);
|
||||
expect(persisted.name).toBe(newName);
|
||||
expect(persisted.description).toBe(values.description);
|
||||
expect(persisted.gmNotes).toBe(values.gmNotes);
|
||||
expect(persisted.playerObjectives).toBe(values.playerObjectives);
|
||||
expect(persisted.narrativeStakes).toBe(values.narrativeStakes);
|
||||
});
|
||||
|
||||
test('save button is disabled when name is empty', async ({ page }) => {
|
||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/edit`);
|
||||
const nameField = page.getByLabel(/Titre du chapitre/i);
|
||||
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
|
||||
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
await nameField.fill('');
|
||||
await expect(saveBtn).toBeDisabled();
|
||||
await nameField.fill('OK');
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
});
|
||||
});
|
||||
64
web/e2e/tests/campaign/scene-create.spec.ts
Normal file
64
web/e2e/tests/campaign/scene-create.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedCampaign,
|
||||
seedArc,
|
||||
seedChapter,
|
||||
deleteCampaign,
|
||||
getSceneById,
|
||||
type SeededCampaign,
|
||||
type SeededArc,
|
||||
type SeededChapter,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('Scene creation', () => {
|
||||
let campaign: SeededCampaign;
|
||||
let arc: SeededArc;
|
||||
let chapter: SeededChapter;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
campaign = await seedCampaign(request);
|
||||
arc = await seedArc(request, { campaignId: campaign.id });
|
||||
chapter = await seedChapter(request, { arcId: arc.id });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||
});
|
||||
|
||||
test('creates a scene and redirects to its view', async ({ page, request }) => {
|
||||
const sceneName = `Scène ${Date.now()}`;
|
||||
|
||||
await page.goto(
|
||||
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/create`,
|
||||
);
|
||||
await expect(page.getByRole('heading', { name: /Créer une nouvelle scène/i })).toBeVisible();
|
||||
|
||||
await page.getByLabel(/Nom de la scène/i).fill(sceneName);
|
||||
await page.getByLabel(/Description/i).fill('Résumé rapide de la scène.');
|
||||
|
||||
await page.getByRole('button', { name: /^Créer la scène$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(
|
||||
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/\\d+$`,
|
||||
),
|
||||
);
|
||||
|
||||
const createdId = page.url().match(/\/scenes\/(\d+)$/)?.[1];
|
||||
expect(createdId).toBeTruthy();
|
||||
const persisted = await getSceneById(request, createdId!);
|
||||
expect(persisted.name).toBe(sceneName);
|
||||
});
|
||||
|
||||
test('submit is disabled when name is empty', async ({ page }) => {
|
||||
await page.goto(
|
||||
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/create`,
|
||||
);
|
||||
|
||||
const submit = page.getByRole('button', { name: /^Créer la scène$/i });
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
await page.getByLabel(/Nom de la scène/i).fill('OK');
|
||||
await expect(submit).toBeEnabled();
|
||||
});
|
||||
});
|
||||
147
web/e2e/tests/campaign/scene-edit.spec.ts
Normal file
147
web/e2e/tests/campaign/scene-edit.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedCampaign,
|
||||
seedArc,
|
||||
seedChapter,
|
||||
seedScene,
|
||||
deleteCampaign,
|
||||
getSceneById,
|
||||
type SeededCampaign,
|
||||
type SeededArc,
|
||||
type SeededChapter,
|
||||
type SeededScene,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('Scene edit', () => {
|
||||
let campaign: SeededCampaign;
|
||||
let arc: SeededArc;
|
||||
let chapter: SeededChapter;
|
||||
let scene: SeededScene;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
campaign = await seedCampaign(request);
|
||||
arc = await seedArc(request, { campaignId: campaign.id });
|
||||
chapter = await seedChapter(request, { arcId: arc.id });
|
||||
scene = await seedScene(request, { chapterId: chapter.id });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||
});
|
||||
|
||||
test('edits all text fields across sections and persists them to API', async ({ page, request }) => {
|
||||
const newName = `${scene.name} renamed`;
|
||||
const values = {
|
||||
description: "Les PJ arrivent au village à la nuit tombée.",
|
||||
location: "Taverne du Dragon d'Or",
|
||||
timing: 'Soir, pleine lune',
|
||||
atmosphere: 'Silence pesant, regards fuyants des villageois.',
|
||||
playerNarration: 'Vous poussez la porte de la taverne…',
|
||||
gmSecretNotes: 'Le tavernier est complice des bandits.',
|
||||
choicesConsequences: 'Accepter = piégés à l\'étage. Refuser = filature.',
|
||||
combatDifficulty: 'Moyenne, 4 bandits niveau 3',
|
||||
enemies: 'Bandit chef (feuille jointe) + 3 sbires.',
|
||||
};
|
||||
|
||||
await page.goto(
|
||||
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}/edit`,
|
||||
);
|
||||
|
||||
await expect(page.getByLabel(/Titre de la scène/i)).toHaveValue(scene.name);
|
||||
|
||||
await page.getByLabel(/Titre de la scène/i).fill(newName);
|
||||
await page.getByLabel(/Description courte/i).fill(values.description);
|
||||
await page.getByLabel(/^Lieu$/i).fill(values.location);
|
||||
await page.getByLabel(/^Moment$/i).fill(values.timing);
|
||||
await page.getByLabel(/Ambiance et atmosphère/i).fill(values.atmosphere);
|
||||
|
||||
// Les sections suivantes sont fermées par défaut : on les ouvre avant de taper.
|
||||
// Un clic sur le header de la section toggle son état.
|
||||
await page.locator('app-expandable-section', { hasText: 'Narration pour les joueurs' }).click();
|
||||
await page.getByPlaceholder(/Le texte que vous lirez aux joueurs/i).fill(values.playerNarration);
|
||||
|
||||
await page.locator('app-expandable-section', { hasText: 'Notes et secrets du MJ' }).click();
|
||||
await page
|
||||
.getByPlaceholder(/Informations cachées, indices, éléments secrets/i)
|
||||
.fill(values.gmSecretNotes);
|
||||
|
||||
await page.locator('app-expandable-section', { hasText: 'Choix et conséquences' }).click();
|
||||
await page
|
||||
.getByPlaceholder(/Décrivez les différentes options/i)
|
||||
.fill(values.choicesConsequences);
|
||||
|
||||
await page.locator('app-expandable-section', { hasText: 'Combat ou rencontre' }).click();
|
||||
await page.getByLabel(/Difficulté estimée/i).fill(values.combatDifficulty);
|
||||
await page.getByLabel(/Ennemis et créatures/i).fill(values.enemies);
|
||||
|
||||
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(
|
||||
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}$`,
|
||||
),
|
||||
);
|
||||
|
||||
const persisted = await getSceneById(request, scene.id);
|
||||
expect(persisted.name).toBe(newName);
|
||||
expect(persisted.description).toBe(values.description);
|
||||
expect(persisted.location).toBe(values.location);
|
||||
expect(persisted.timing).toBe(values.timing);
|
||||
expect(persisted.atmosphere).toBe(values.atmosphere);
|
||||
expect(persisted.playerNarration).toBe(values.playerNarration);
|
||||
expect(persisted.gmSecretNotes).toBe(values.gmSecretNotes);
|
||||
expect(persisted.choicesConsequences).toBe(values.choicesConsequences);
|
||||
expect(persisted.combatDifficulty).toBe(values.combatDifficulty);
|
||||
expect(persisted.enemies).toBe(values.enemies);
|
||||
});
|
||||
|
||||
test('adds a narrative branch to a sibling scene and persists it', async ({ page, request }) => {
|
||||
const sibling = await seedScene(request, {
|
||||
chapterId: chapter.id,
|
||||
name: 'Scène alternative',
|
||||
order: 2,
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}/edit`,
|
||||
);
|
||||
|
||||
const branchesSection = page.locator('app-expandable-section', { hasText: 'Branches narratives' });
|
||||
await branchesSection.click();
|
||||
|
||||
await branchesSection.getByRole('button', { name: /Ajouter une branche/i }).click();
|
||||
|
||||
const branchItem = branchesSection.locator('.branch-item').first();
|
||||
await branchItem.getByPlaceholder(/Ex: Si les joueurs attaquent/i).fill('Si les PJ fuient');
|
||||
await branchItem.locator('select').selectOption({ label: sibling.name });
|
||||
await branchItem.getByPlaceholder(/Jet de Persuasion/i).fill('Sur échec initiative');
|
||||
|
||||
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(
|
||||
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}$`,
|
||||
),
|
||||
);
|
||||
|
||||
const persisted = await getSceneById(request, scene.id);
|
||||
expect(persisted.branches).toHaveLength(1);
|
||||
expect(persisted.branches![0].label).toBe('Si les PJ fuient');
|
||||
expect(persisted.branches![0].targetSceneId).toBe(sibling.id);
|
||||
expect(persisted.branches![0].condition).toBe('Sur échec initiative');
|
||||
});
|
||||
|
||||
test('save button is disabled when name is empty', async ({ page }) => {
|
||||
await page.goto(
|
||||
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}/edit`,
|
||||
);
|
||||
const nameField = page.getByLabel(/Titre de la scène/i);
|
||||
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
|
||||
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
await nameField.fill('');
|
||||
await expect(saveBtn).toBeDisabled();
|
||||
await nameField.fill('OK');
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
});
|
||||
});
|
||||
77
web/e2e/tests/lore/lore-create.spec.ts
Normal file
77
web/e2e/tests/lore/lore-create.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { deleteLore } from '../../fixtures/api';
|
||||
|
||||
test.describe('Lore creation', () => {
|
||||
const createdLoreIds: string[] = [];
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
while (createdLoreIds.length) {
|
||||
const id = createdLoreIds.pop()!;
|
||||
await deleteLore(request, id);
|
||||
}
|
||||
});
|
||||
|
||||
test('opens the modal, creates a lore, and shows it in the grid', async ({ page, request }) => {
|
||||
const loreName = `Univers E2E ${Date.now()}`;
|
||||
const description = "Un univers créé par les tests automatisés.";
|
||||
|
||||
await page.goto('/lore');
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Vos Univers/i })).toBeVisible();
|
||||
|
||||
await page.locator('.lore-card.card-new').click();
|
||||
|
||||
const modal = page.locator('.modal');
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(modal.getByRole('heading', { name: /Créer un nouveau Lore/i })).toBeVisible();
|
||||
|
||||
await modal.getByLabel(/Nom de l'univers/i).fill(loreName);
|
||||
await modal.getByLabel('Description').fill(description);
|
||||
|
||||
await modal.getByRole('button', { name: /^Créer le lore$/i }).click();
|
||||
|
||||
await expect(modal).not.toBeVisible();
|
||||
|
||||
const newCard = page.locator('.lore-card', { hasText: loreName });
|
||||
await expect(newCard).toBeVisible();
|
||||
await expect(newCard).toContainText(description);
|
||||
|
||||
const allLores = await request.get('/api/lores').then((r) => r.json());
|
||||
const created = allLores.find((l: { name: string; id: string }) => l.name === loreName);
|
||||
expect(created).toBeDefined();
|
||||
createdLoreIds.push(created.id);
|
||||
});
|
||||
|
||||
test('submit button is disabled when name is empty', async ({ page }) => {
|
||||
await page.goto('/lore');
|
||||
await page.locator('.lore-card.card-new').click();
|
||||
|
||||
const modal = page.locator('.modal');
|
||||
const submit = modal.getByRole('button', { name: /^Créer le lore$/i });
|
||||
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
await modal.getByLabel(/Nom de l'univers/i).fill('Quelque chose');
|
||||
await expect(submit).toBeEnabled();
|
||||
|
||||
await modal.getByLabel(/Nom de l'univers/i).fill('');
|
||||
await expect(submit).toBeDisabled();
|
||||
});
|
||||
|
||||
test('clicking the backdrop closes the modal without creating', async ({ page }) => {
|
||||
const typedButAbandoned = `Univers abandonné ${Date.now()}`;
|
||||
|
||||
await page.goto('/lore');
|
||||
await page.locator('.lore-card.card-new').click();
|
||||
|
||||
const modal = page.locator('.modal');
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
await modal.getByLabel(/Nom de l'univers/i).fill(typedButAbandoned);
|
||||
|
||||
await page.locator('.modal-backdrop').click({ position: { x: 5, y: 5 } });
|
||||
await expect(modal).not.toBeVisible();
|
||||
|
||||
await expect(page.locator('.lore-card', { hasText: typedButAbandoned })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
53
web/e2e/tests/lore/lore-delete.spec.ts
Normal file
53
web/e2e/tests/lore/lore-delete.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { seedLoreWithFolder, deleteLore, type SeededLore } from '../../fixtures/api';
|
||||
|
||||
test.describe('Lore delete', () => {
|
||||
let seeded: SeededLore;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
seeded = await seedLoreWithFolder(request);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
// Best-effort cleanup — ne fait rien si déjà supprimé par le test.
|
||||
if (seeded?.id) await deleteLore(request, seeded.id);
|
||||
});
|
||||
|
||||
test('deletes a lore after accepting the confirm and redirects to the list', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
let confirmMessage = '';
|
||||
page.on('dialog', async (dialog) => {
|
||||
confirmMessage = dialog.message();
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
await page.goto(`/lore/${seeded.id}`);
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
|
||||
// Attente du dialog et du retour sur la liste des lores.
|
||||
await expect(page).toHaveURL(/\/lore$/);
|
||||
expect(confirmMessage).toContain(seeded.name);
|
||||
// Lore contient un dossier seedé : le récapitulatif doit l'indiquer.
|
||||
expect(confirmMessage).toMatch(/1 dossier/i);
|
||||
|
||||
const res = await request.get(`/api/lores/${seeded.id}`);
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('keeps the lore when the confirm is dismissed', async ({ page, request }) => {
|
||||
page.on('dialog', async (dialog) => {
|
||||
await dialog.dismiss();
|
||||
});
|
||||
|
||||
await page.goto(`/lore/${seeded.id}`);
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
|
||||
// On reste sur le détail, le titre du lore est toujours visible.
|
||||
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);
|
||||
|
||||
const res = await request.get(`/api/lores/${seeded.id}`);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
70
web/e2e/tests/lore/lore-detail-edit.spec.ts
Normal file
70
web/e2e/tests/lore/lore-detail-edit.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { seedLoreWithFolder, deleteLore, getLoreById, type SeededLore } from '../../fixtures/api';
|
||||
|
||||
test.describe('Lore inline edit (on detail page)', () => {
|
||||
let seeded: SeededLore;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
seeded = await seedLoreWithFolder(request);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (seeded?.id) await deleteLore(request, seeded.id);
|
||||
});
|
||||
|
||||
test('renames the lore inline and persists the change', async ({ page, request }) => {
|
||||
const newName = `${seeded.name} renamed`;
|
||||
const newDescription = 'Nouvelle description éditée via UI';
|
||||
|
||||
await page.goto(`/lore/${seeded.id}`);
|
||||
|
||||
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);
|
||||
|
||||
await page.getByRole('button', { name: /^Modifier$/i }).click();
|
||||
|
||||
const nameInput = page.getByLabel(/^Nom$/);
|
||||
const descInput = page.getByLabel(/^Description$/);
|
||||
await expect(nameInput).toHaveValue(seeded.name);
|
||||
|
||||
await nameInput.fill(newName);
|
||||
await descInput.fill(newDescription);
|
||||
|
||||
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||
|
||||
// Sortie du mode édition : le header bascule en mode lecture avec la nouvelle valeur.
|
||||
await expect(page.locator('.detail-header h1')).toHaveText(newName);
|
||||
await expect(page.locator('.detail-header .description')).toHaveText(newDescription);
|
||||
|
||||
const persisted = await getLoreById(request, seeded.id);
|
||||
expect(persisted.name).toBe(newName);
|
||||
expect(persisted.description).toBe(newDescription);
|
||||
});
|
||||
|
||||
test('save button is disabled when name is emptied during edit', async ({ page }) => {
|
||||
await page.goto(`/lore/${seeded.id}`);
|
||||
await page.getByRole('button', { name: /^Modifier$/i }).click();
|
||||
|
||||
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
|
||||
await page.getByLabel(/^Nom$/).fill('');
|
||||
await expect(saveBtn).toBeDisabled();
|
||||
|
||||
await page.getByLabel(/^Nom$/).fill(' ');
|
||||
await expect(saveBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
test('cancel discards the in-flight edits', async ({ page, request }) => {
|
||||
await page.goto(`/lore/${seeded.id}`);
|
||||
await page.getByRole('button', { name: /^Modifier$/i }).click();
|
||||
|
||||
await page.getByLabel(/^Nom$/).fill('Name jamais sauvegardé');
|
||||
await page.getByRole('button', { name: /^Annuler$/i }).click();
|
||||
|
||||
// Retour en mode lecture avec le nom d'origine.
|
||||
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);
|
||||
|
||||
const persisted = await getLoreById(request, seeded.id);
|
||||
expect(persisted.name).toBe(seeded.name);
|
||||
});
|
||||
});
|
||||
90
web/e2e/tests/lore/page-create.spec.ts
Normal file
90
web/e2e/tests/lore/page-create.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedLoreWithFolder,
|
||||
seedTemplate,
|
||||
deleteLore,
|
||||
getPagesForLore,
|
||||
type SeededLore,
|
||||
type SeededTemplate,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('Page creation', () => {
|
||||
let seeded: SeededLore;
|
||||
let template: SeededTemplate;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
seeded = await seedLoreWithFolder(request);
|
||||
template = await seedTemplate(request, {
|
||||
loreId: seeded.id,
|
||||
defaultNodeId: seeded.rootFolderId,
|
||||
fieldNames: ['Apparence', 'Motivation'],
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (seeded?.id) await deleteLore(request, seeded.id);
|
||||
});
|
||||
|
||||
test('creates an empty page from a template and redirects to edit', async ({ page, request }) => {
|
||||
const pageTitle = `Maître Eldrin ${Date.now()}`;
|
||||
|
||||
await page.goto(`/lore/${seeded.id}/pages/create`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Créer une nouvelle Page/i })).toBeVisible();
|
||||
|
||||
await page.getByLabel(/Titre de la page/i).fill(pageTitle);
|
||||
|
||||
await page.locator('.template-card', { hasText: template.name }).click();
|
||||
await expect(page.locator('.template-card.selected', { hasText: template.name })).toBeVisible();
|
||||
|
||||
await expect(page.locator('#page-node')).toHaveValue(seeded.rootFolderId);
|
||||
|
||||
await page.getByRole('button', { name: /^Créer la page$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/[^/]+/edit$`));
|
||||
|
||||
const pages = await getPagesForLore(request, seeded.id);
|
||||
const created = pages.find((p) => p.title === pageTitle);
|
||||
expect(created).toBeDefined();
|
||||
expect(created?.templateId).toBe(template.id);
|
||||
expect(created?.nodeId).toBe(seeded.rootFolderId);
|
||||
});
|
||||
|
||||
test('submit is disabled until title, template and folder are set', async ({ page }) => {
|
||||
await page.goto(`/lore/${seeded.id}/pages/create`);
|
||||
|
||||
const submit = page.getByRole('button', { name: /^Créer la page$/i });
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
await page.getByLabel(/Titre de la page/i).fill('Un titre');
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
await page.locator('.template-card', { hasText: template.name }).click();
|
||||
await expect(submit).toBeEnabled();
|
||||
});
|
||||
|
||||
test('entering on a folder-scoped route preselects that folder', async ({ page, request }) => {
|
||||
const pageTitle = `Page scoped ${Date.now()}`;
|
||||
|
||||
const secondFolderRes = await request.post('/api/lore-nodes', {
|
||||
data: { loreId: seeded.id, name: 'Autre dossier', icon: 'folder', description: '' },
|
||||
});
|
||||
expect(secondFolderRes.ok()).toBeTruthy();
|
||||
const secondFolderId = (await secondFolderRes.json()).id;
|
||||
|
||||
await page.goto(`/lore/${seeded.id}/nodes/${secondFolderId}/pages/create`);
|
||||
|
||||
const nodeSelect = page.locator('#page-node');
|
||||
await expect(nodeSelect).toHaveValue(secondFolderId);
|
||||
await expect(nodeSelect).toBeDisabled();
|
||||
|
||||
await page.getByLabel(/Titre de la page/i).fill(pageTitle);
|
||||
await page.locator('.template-card', { hasText: template.name }).click();
|
||||
await page.getByRole('button', { name: /^Créer la page$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/[^/]+/edit$`));
|
||||
|
||||
const pages = await getPagesForLore(request, seeded.id);
|
||||
expect(pages.find((p) => p.title === pageTitle)?.nodeId).toBe(secondFolderId);
|
||||
});
|
||||
});
|
||||
58
web/e2e/tests/lore/page-delete.spec.ts
Normal file
58
web/e2e/tests/lore/page-delete.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedLoreWithFolder,
|
||||
seedTemplate,
|
||||
seedPage,
|
||||
deleteLore,
|
||||
type SeededLore,
|
||||
type SeededTemplate,
|
||||
type SeededPage,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('Page delete', () => {
|
||||
let seeded: SeededLore;
|
||||
let template: SeededTemplate;
|
||||
let pageEntity: SeededPage;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
seeded = await seedLoreWithFolder(request);
|
||||
template = await seedTemplate(request, {
|
||||
loreId: seeded.id,
|
||||
defaultNodeId: seeded.rootFolderId,
|
||||
});
|
||||
pageEntity = await seedPage(request, {
|
||||
loreId: seeded.id,
|
||||
nodeId: seeded.rootFolderId,
|
||||
templateId: template.id,
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (seeded?.id) await deleteLore(request, seeded.id);
|
||||
});
|
||||
|
||||
test('deletes the page after accepting confirm', async ({ page, request }) => {
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
|
||||
// Le composant redirige vers la racine du Lore après suppression.
|
||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||
|
||||
const res = await request.get(`/api/pages/${pageEntity.id}`);
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('keeps the page when confirm is dismissed', async ({ page, request }) => {
|
||||
page.on('dialog', (dialog) => dialog.dismiss());
|
||||
|
||||
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}/edit$`));
|
||||
|
||||
const res = await request.get(`/api/pages/${pageEntity.id}`);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
95
web/e2e/tests/lore/page-edit.spec.ts
Normal file
95
web/e2e/tests/lore/page-edit.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedLoreWithFolder,
|
||||
seedTemplate,
|
||||
seedPage,
|
||||
deleteLore,
|
||||
getPageById,
|
||||
type SeededLore,
|
||||
type SeededTemplate,
|
||||
type SeededPage,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('Page edit', () => {
|
||||
let seeded: SeededLore;
|
||||
let template: SeededTemplate;
|
||||
let pageEntity: SeededPage;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
seeded = await seedLoreWithFolder(request);
|
||||
template = await seedTemplate(request, {
|
||||
loreId: seeded.id,
|
||||
defaultNodeId: seeded.rootFolderId,
|
||||
fieldNames: ['Apparence', 'Motivation'],
|
||||
});
|
||||
pageEntity = await seedPage(request, {
|
||||
loreId: seeded.id,
|
||||
nodeId: seeded.rootFolderId,
|
||||
templateId: template.id,
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (seeded?.id) await deleteLore(request, seeded.id);
|
||||
});
|
||||
|
||||
test('edits title, dynamic fields, notes and persists to API', async ({ page, request }) => {
|
||||
const newTitle = `Maître Eldrin ${Date.now()}`;
|
||||
const apparence = 'Vieil homme au regard perçant.';
|
||||
const motivation = 'Protéger la cité à tout prix.';
|
||||
const notes = 'MJ : il connaît le secret du roi.';
|
||||
|
||||
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
||||
|
||||
await expect(page.locator('input[name="title"]')).toHaveValue(pageEntity.title);
|
||||
|
||||
await page.locator('input[name="title"]').fill(newTitle);
|
||||
await page.getByPlaceholder('Valeur pour Apparence...').fill(apparence);
|
||||
await page.getByPlaceholder('Valeur pour Motivation...').fill(motivation);
|
||||
await page.locator('textarea[name="notes"]').fill(notes);
|
||||
|
||||
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}$`));
|
||||
|
||||
const persisted = await getPageById(request, pageEntity.id);
|
||||
expect(persisted.title).toBe(newTitle);
|
||||
expect(persisted.values?.['Apparence']).toBe(apparence);
|
||||
expect(persisted.values?.['Motivation']).toBe(motivation);
|
||||
expect(persisted.notes).toBe(notes);
|
||||
});
|
||||
|
||||
test('adds a tag via chips input and persists it', async ({ page, request }) => {
|
||||
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
||||
|
||||
const chipsInput = page.locator('app-chips-input input');
|
||||
await chipsInput.fill('dangereux');
|
||||
await chipsInput.press('Enter');
|
||||
await chipsInput.fill('ancien');
|
||||
await chipsInput.press('Enter');
|
||||
|
||||
await expect(page.locator('app-chips-input').getByText('dangereux')).toBeVisible();
|
||||
await expect(page.locator('app-chips-input').getByText('ancien')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}$`));
|
||||
|
||||
const persisted = await getPageById(request, pageEntity.id);
|
||||
expect(persisted.tags).toEqual(expect.arrayContaining(['dangereux', 'ancien']));
|
||||
});
|
||||
|
||||
test('save button is disabled when title is empty', async ({ page }) => {
|
||||
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
||||
|
||||
const titleInput = page.locator('input[name="title"]');
|
||||
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
|
||||
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
await titleInput.fill('');
|
||||
await expect(saveBtn).toBeDisabled();
|
||||
await titleInput.fill(' ');
|
||||
await expect(saveBtn).toBeDisabled();
|
||||
await titleInput.fill('OK');
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
});
|
||||
});
|
||||
72
web/e2e/tests/lore/template-create.spec.ts
Normal file
72
web/e2e/tests/lore/template-create.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { seedLoreWithFolder, deleteLore, getTemplatesForLore, type SeededLore } from '../../fixtures/api';
|
||||
|
||||
test.describe('Template creation', () => {
|
||||
let seeded: SeededLore;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
seeded = await seedLoreWithFolder(request);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (seeded?.id) await deleteLore(request, seeded.id);
|
||||
});
|
||||
|
||||
test('creates a template with default fields', async ({ page, request }) => {
|
||||
const templateName = `Auberge ${Date.now()}`;
|
||||
|
||||
await page.goto(`/lore/${seeded.id}/templates/create`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Créer un nouveau Template/i })).toBeVisible();
|
||||
|
||||
await page.getByLabel(/Nom du template/i).fill(templateName);
|
||||
await page.getByLabel('Description').fill('Template créé via E2E');
|
||||
await page.getByLabel(/Dossier par défaut/i).selectOption({ label: seeded.rootFolderName });
|
||||
|
||||
await page.getByRole('button', { name: /^Créer le template$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||
|
||||
const templates = await getTemplatesForLore(request, seeded.id);
|
||||
expect(templates.map((t) => t.name)).toContain(templateName);
|
||||
});
|
||||
|
||||
test('submit is disabled when name is empty', async ({ page }) => {
|
||||
await page.goto(`/lore/${seeded.id}/templates/create`);
|
||||
|
||||
const submit = page.getByRole('button', { name: /^Créer le template$/i });
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
await page.getByLabel(/Nom du template/i).fill('Valid name');
|
||||
await page.getByLabel(/Dossier par défaut/i).selectOption({ label: seeded.rootFolderName });
|
||||
await expect(submit).toBeEnabled();
|
||||
});
|
||||
|
||||
test('can add and remove a custom field before creating', async ({ page, request }) => {
|
||||
const templateName = `Artefact ${Date.now()}`;
|
||||
|
||||
await page.goto(`/lore/${seeded.id}/templates/create`);
|
||||
|
||||
await page.getByLabel(/Nom du template/i).fill(templateName);
|
||||
await page.getByLabel(/Dossier par défaut/i).selectOption({ label: seeded.rootFolderName });
|
||||
|
||||
const addFieldInput = page.getByPlaceholder('+ Ajouter un champ');
|
||||
await addFieldInput.fill('Pouvoir');
|
||||
await addFieldInput.press('Enter');
|
||||
await expect(page.locator('.fields-list .field-chip', { hasText: 'Pouvoir' })).toBeVisible();
|
||||
|
||||
await addFieldInput.fill('Origine');
|
||||
await addFieldInput.press('Enter');
|
||||
await expect(page.locator('.fields-list .field-chip', { hasText: 'Origine' })).toBeVisible();
|
||||
|
||||
const origineRow = page.locator('.fields-list .field-row', { hasText: 'Origine' });
|
||||
await origineRow.getByRole('button', { name: 'Supprimer' }).click();
|
||||
await expect(page.locator('.fields-list .field-chip', { hasText: 'Origine' })).toHaveCount(0);
|
||||
|
||||
await page.getByRole('button', { name: /^Créer le template$/i }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||
|
||||
const templates = await getTemplatesForLore(request, seeded.id);
|
||||
expect(templates.find((t) => t.name === templateName)).toBeDefined();
|
||||
});
|
||||
});
|
||||
51
web/e2e/tests/lore/template-delete.spec.ts
Normal file
51
web/e2e/tests/lore/template-delete.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedLoreWithFolder,
|
||||
seedTemplate,
|
||||
deleteLore,
|
||||
getTemplatesForLore,
|
||||
type SeededLore,
|
||||
type SeededTemplate,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('Template delete', () => {
|
||||
let seeded: SeededLore;
|
||||
let template: SeededTemplate;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
seeded = await seedLoreWithFolder(request);
|
||||
template = await seedTemplate(request, {
|
||||
loreId: seeded.id,
|
||||
defaultNodeId: seeded.rootFolderId,
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (seeded?.id) await deleteLore(request, seeded.id);
|
||||
});
|
||||
|
||||
test('deletes the template after accepting confirm', async ({ page, request }) => {
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
||||
await page.locator('.page-header .btn-danger').click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||
|
||||
const templates = await getTemplatesForLore(request, seeded.id);
|
||||
expect(templates.find((t) => t.id === template.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('keeps the template when confirm is dismissed', async ({ page, request }) => {
|
||||
page.on('dialog', (dialog) => dialog.dismiss());
|
||||
|
||||
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
||||
await page.locator('.page-header .btn-danger').click();
|
||||
|
||||
// On reste sur l'écran d'édition (l'URL ne change pas).
|
||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/templates/${template.id}$`));
|
||||
|
||||
const templates = await getTemplatesForLore(request, seeded.id);
|
||||
expect(templates.find((t) => t.id === template.id)).toBeDefined();
|
||||
});
|
||||
});
|
||||
74
web/e2e/tests/lore/template-edit.spec.ts
Normal file
74
web/e2e/tests/lore/template-edit.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedLoreWithFolder,
|
||||
seedTemplate,
|
||||
deleteLore,
|
||||
getTemplateById,
|
||||
type SeededLore,
|
||||
type SeededTemplate,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('Template edit', () => {
|
||||
let seeded: SeededLore;
|
||||
let template: SeededTemplate;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
seeded = await seedLoreWithFolder(request);
|
||||
template = await seedTemplate(request, {
|
||||
loreId: seeded.id,
|
||||
defaultNodeId: seeded.rootFolderId,
|
||||
fieldNames: ['Nom', 'Description'],
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (seeded?.id) await deleteLore(request, seeded.id);
|
||||
});
|
||||
|
||||
test('form is prefilled with the current template data', async ({ page }) => {
|
||||
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
||||
|
||||
await expect(page.getByLabel(/^Nom$/)).toHaveValue(template.name);
|
||||
await expect(page.getByLabel(/Dossier par défaut/i)).toHaveValue(seeded.rootFolderId);
|
||||
await expect(page.locator('.fields-list .field-chip', { hasText: 'Nom' })).toBeVisible();
|
||||
await expect(page.locator('.fields-list .field-chip', { hasText: 'Description' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('renames the template and persists to API', async ({ page, request }) => {
|
||||
const newName = `${template.name} renamed`;
|
||||
|
||||
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
||||
|
||||
const nameInput = page.getByLabel(/^Nom$/);
|
||||
await nameInput.fill(newName);
|
||||
await page.getByLabel(/Description/i).fill('Description mise à jour');
|
||||
|
||||
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||
|
||||
const persisted = await getTemplateById(request, template.id);
|
||||
expect(persisted.name).toBe(newName);
|
||||
expect(persisted.description).toBe('Description mise à jour');
|
||||
});
|
||||
|
||||
test('adds a new field, removes an existing one, and persists the order', async ({ page, request }) => {
|
||||
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
||||
|
||||
const addInput = page.getByPlaceholder('+ Ajouter un champ');
|
||||
await addInput.fill('Stats');
|
||||
await addInput.press('Enter');
|
||||
await expect(page.locator('.fields-list .field-chip', { hasText: 'Stats' })).toBeVisible();
|
||||
|
||||
const descriptionRow = page.locator('.fields-list .field-row', { hasText: 'Description' });
|
||||
await descriptionRow.getByRole('button', { name: 'Supprimer' }).click();
|
||||
await expect(page.locator('.fields-list .field-chip', { hasText: 'Description' })).toHaveCount(0);
|
||||
|
||||
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||
|
||||
const persisted = await getTemplateById(request, template.id);
|
||||
const names = persisted.fields.map((f) => f.name);
|
||||
expect(names).toEqual(['Nom', 'Stats']);
|
||||
});
|
||||
});
|
||||
15
web/e2e/tests/smoke.spec.ts
Normal file
15
web/e2e/tests/smoke.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Smoke', () => {
|
||||
test('app loads without uncaught errors', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err.message));
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.locator('app-sidebar')).toBeVisible();
|
||||
await expect(page.locator('main.main-content')).toBeAttached();
|
||||
|
||||
expect(errors, `Page errors:\n${errors.join('\n')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
68
web/package-lock.json
generated
68
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "loremind-web",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.1",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.0.0",
|
||||
"@angular/common": "^17.0.0",
|
||||
@@ -27,6 +27,7 @@
|
||||
"@angular-devkit/build-angular": "^17.0.0",
|
||||
"@angular/cli": "^17.0.0",
|
||||
"@angular/compiler-cli": "^17.0.0",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"typescript": "~5.2.2"
|
||||
}
|
||||
},
|
||||
@@ -3137,6 +3138,22 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
|
||||
@@ -9063,6 +9080,53 @@
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.35",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.2",
|
||||
"description": "LoreMind Frontend - Angular",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
"test": "ng test",
|
||||
"e2e": "playwright test",
|
||||
"e2e:ui": "playwright test --ui",
|
||||
"e2e:headed": "playwright test --headed",
|
||||
"e2e:report": "playwright show-report"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
@@ -30,6 +34,7 @@
|
||||
"@angular-devkit/build-angular": "^17.0.0",
|
||||
"@angular/cli": "^17.0.0",
|
||||
"@angular/compiler-cli": "^17.0.0",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"typescript": "~5.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
24
web/playwright.config.ts
Normal file
24
web/playwright.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const baseURL = process.env['E2E_BASE_URL'] || 'http://localhost:8081';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e/tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env['CI'],
|
||||
retries: process.env['CI'] ? 2 : 0,
|
||||
workers: process.env['CI'] ? 1 : undefined,
|
||||
reporter: process.env['CI'] ? [['html', { open: 'never' }], ['list']] : 'html',
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
8
web/proxy.conf.json
Normal file
8
web/proxy.conf.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:8080",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "warn"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { hiddenInDemoGuard } from './guards/demo-mode.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) },
|
||||
{ path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) },
|
||||
{ path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
|
||||
{ path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
|
||||
{ path: 'lore/:loreId/folders/:folderId', loadComponent: () => import('./lore/folder-view/folder-view.component').then(m => m.FolderViewComponent) },
|
||||
{ path: 'lore/:loreId/folders/:folderId/edit', loadComponent: () => import('./lore/lore-node-edit/lore-node-edit.component').then(m => m.LoreNodeEditComponent) },
|
||||
{ path: 'lore/:loreId/templates/create', loadComponent: () => import('./lore/template-create/template-create.component').then(m => m.TemplateCreateComponent) },
|
||||
{ path: 'lore/:loreId/templates/:templateId', loadComponent: () => import('./lore/template-edit/template-edit.component').then(m => m.TemplateEditComponent) },
|
||||
@@ -29,6 +31,8 @@ export const routes: Routes = [
|
||||
{ path: 'game-systems', loadComponent: () => import('./game-systems/game-systems.component').then(m => m.GameSystemsComponent) },
|
||||
{ path: 'game-systems/create', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },
|
||||
{ path: 'game-systems/:id/edit', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },
|
||||
{ path: 'settings', loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent) },
|
||||
// Routes masquees en mode demo : ajouter canActivate: [hiddenInDemoGuard]
|
||||
// (a prevoir aussi sur la future route d'export VTT).
|
||||
{ path: 'settings', canActivate: [hiddenInDemoGuard], loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent) },
|
||||
{ path: '', redirectTo: '/lore', pathMatch: 'full' }
|
||||
];
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="arc-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom de l'arc *</label>
|
||||
<label for="arc-create-name">Nom de l'arc *</label>
|
||||
<input
|
||||
id="arc-create-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: L'Ombre du Nord"
|
||||
@@ -17,8 +18,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<label for="arc-create-description">Description</label>
|
||||
<textarea
|
||||
id="arc-create-description"
|
||||
formControlName="description"
|
||||
placeholder="Décrivez l'arc narratif principal..."
|
||||
rows="5">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, BookOpen } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { Campaign } from '../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
@@ -33,6 +34,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private layoutService: LayoutService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
@@ -50,7 +52,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
this.existingArcCount = treeData.arcs.length;
|
||||
|
||||
|
||||
@@ -43,8 +43,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Titre de l'arc *</label>
|
||||
<label for="arc-edit-name">Titre de l'arc *</label>
|
||||
<input
|
||||
id="arc-edit-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: L'Ombre du Nord"
|
||||
@@ -53,8 +54,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Synopsis de l'arc</label>
|
||||
<label for="arc-edit-description">Synopsis de l'arc</label>
|
||||
<textarea
|
||||
id="arc-edit-description"
|
||||
formControlName="description"
|
||||
placeholder="Décrivez l'histoire principale de cet arc narratif..."
|
||||
rows="5">
|
||||
@@ -63,16 +65,18 @@
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Thèmes principaux</label>
|
||||
<label for="arc-edit-themes">Thèmes principaux</label>
|
||||
<textarea
|
||||
id="arc-edit-themes"
|
||||
formControlName="themes"
|
||||
placeholder="Quels sont les thèmes explorés dans cet arc ? (trahison, rédemption...)"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Enjeux globaux</label>
|
||||
<label for="arc-edit-stakes">Enjeux globaux</label>
|
||||
<textarea
|
||||
id="arc-edit-stakes"
|
||||
formControlName="stakes"
|
||||
placeholder="Quels sont les enjeux majeurs de cet arc pour les personnages ?"
|
||||
rows="4">
|
||||
@@ -81,8 +85,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Notes et planification du MJ</label>
|
||||
<label for="arc-edit-gm-notes">Notes et planification du MJ</label>
|
||||
<textarea
|
||||
id="arc-edit-gm-notes"
|
||||
formControlName="gmNotes"
|
||||
placeholder="Vos notes sur la direction de l'arc, les twists prévus, les révélations importantes..."
|
||||
rows="5">
|
||||
@@ -91,8 +96,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Récompenses et progression</label>
|
||||
<label for="arc-edit-rewards">Récompenses et progression</label>
|
||||
<textarea
|
||||
id="arc-edit-rewards"
|
||||
formControlName="rewards"
|
||||
placeholder="Quelles récompenses les joueurs obtiendront-ils ? Objets, niveaux, connaissances, contacts..."
|
||||
rows="4">
|
||||
@@ -100,8 +106,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Dénouement prévu</label>
|
||||
<label for="arc-edit-resolution">Dénouement prévu</label>
|
||||
<textarea
|
||||
id="arc-edit-resolution"
|
||||
formControlName="resolution"
|
||||
placeholder="Comment cet arc devrait-il se terminer ? Quelles sont les issues possibles ?"
|
||||
rows="4">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
@@ -68,6 +69,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
@@ -105,7 +107,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
arc: this.campaignService.getArcById(this.arcId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
Modifier
|
||||
</button>
|
||||
<button type="button" class="btn-danger" (click)="deleteArc()" title="Supprimer l'arc et tout son contenu">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Pencil } from 'lucide-angular';
|
||||
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
@@ -27,6 +28,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
|
||||
})
|
||||
export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
readonly Pencil = Pencil;
|
||||
readonly Trash2 = Trash2;
|
||||
|
||||
campaignId = '';
|
||||
arcId = '';
|
||||
@@ -41,6 +43,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
@@ -63,7 +66,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
arc: this.campaignService.getArcById(this.arcId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
@@ -101,6 +104,38 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'edit']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppression en cascade : récupère d'abord le compte de chapitres / scènes
|
||||
* qui tomberont avec l'arc, l'annonce dans la confirmation, puis délègue au
|
||||
* backend (transaction atomique).
|
||||
*/
|
||||
deleteArc(): void {
|
||||
if (!this.arc) return;
|
||||
const arc = this.arc;
|
||||
this.campaignService.getArcDeletionImpact(arc.id!).subscribe({
|
||||
next: impact => {
|
||||
const parts: string[] = [];
|
||||
if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`);
|
||||
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
|
||||
|
||||
const lines = [`Supprimer l'arc "${arc.name}" ?`];
|
||||
if (parts.length) {
|
||||
lines.push('');
|
||||
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Cette action est irréversible.');
|
||||
|
||||
if (!confirm(lines.join('\n'))) return;
|
||||
this.campaignService.deleteArc(arc.id!).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||
error: () => console.error('Erreur lors de la suppression de l\'arc')
|
||||
});
|
||||
},
|
||||
error: () => console.error('Impossible de récupérer les dépendances de l\'arc')
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom de la campagne *</label>
|
||||
<label for="campaign-name">Nom de la campagne *</label>
|
||||
<input
|
||||
id="campaign-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: L'Ombre du Nord, Les Héritiers Oubliés..."
|
||||
@@ -21,8 +22,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description / Pitch</label>
|
||||
<label for="campaign-description">Description / Pitch</label>
|
||||
<textarea
|
||||
id="campaign-description"
|
||||
formControlName="description"
|
||||
placeholder="Résumez l'intrigue principale de votre campagne..."
|
||||
rows="5"
|
||||
@@ -30,13 +32,13 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Nombre de joueurs</label>
|
||||
<input type="number" formControlName="playerCount" min="1" />
|
||||
<label for="campaign-player-count">Nombre de joueurs</label>
|
||||
<input id="campaign-player-count" type="number" formControlName="playerCount" min="1" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Univers associé</label>
|
||||
<select formControlName="loreId">
|
||||
<label for="campaign-lore">Univers associé</label>
|
||||
<select id="campaign-lore" formControlName="loreId">
|
||||
<option value="">— Aucun univers (campagne libre) —</option>
|
||||
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option>
|
||||
</select>
|
||||
@@ -47,8 +49,8 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Système de JDR</label>
|
||||
<select formControlName="gameSystemId">
|
||||
<label for="campaign-game-system">Système de JDR</label>
|
||||
<select id="campaign-game-system" formControlName="gameSystemId">
|
||||
<option value="">— Aucun (campagne générique) —</option>
|
||||
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
|
||||
</select>
|
||||
|
||||
@@ -15,6 +15,24 @@
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
// Le contenu peut dépasser la hauteur de l'écran (formulaire long) :
|
||||
// on borne la modale et on fait scroller l'intérieur en flex-column.
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header { flex-shrink: 0; }
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
// Marge interne pour que la scrollbar ne colle pas aux inputs.
|
||||
margin-right: -0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@@ -87,6 +105,14 @@
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
// Actions collées en bas du scroll : visibles même si on n'a pas défilé
|
||||
// jusqu'en bas du formulaire.
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: #111827;
|
||||
padding-top: 1rem;
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="detail-section characters-section">
|
||||
<section class="detail-section characters-section" *ngIf="!editing">
|
||||
<div class="section-header">
|
||||
<h2>Personnages joueurs</h2>
|
||||
<button class="btn-add" (click)="createCharacter()">
|
||||
@@ -99,7 +99,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-section arcs-section">
|
||||
<section class="detail-section arcs-section" *ngIf="!editing">
|
||||
<div class="section-header">
|
||||
<h2>Arcs narratifs</h2>
|
||||
<button class="btn-add" (click)="createArc()">
|
||||
|
||||
@@ -74,6 +74,15 @@
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
// Sticky : Modifier/Supprimer restent accessibles pendant le scroll de la
|
||||
// campagne (potentiellement très longue avec arcs / chapitres / scènes).
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: #0a0a14;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -77,8 +77,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
switchMap(id => forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(id),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, id).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {} } as CampaignTreeData))
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [] } as CampaignTreeData))
|
||||
)
|
||||
}))
|
||||
).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
@@ -111,8 +111,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(id),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, id).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {} } as CampaignTreeData))
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [] } as CampaignTreeData))
|
||||
)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
this.campaign = campaign;
|
||||
@@ -257,22 +257,37 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppression protégée : refus si la campagne contient des arcs.
|
||||
* Les arcs contiennent potentiellement des chapitres/scènes construits longuement.
|
||||
* Suppression en cascade : récupère d'abord le détail de ce qui sera effacé
|
||||
* (arcs / chapitres / scènes / personnages), affiche le récapitulatif dans
|
||||
* la confirmation, puis supprime. Le cascade est orchestré côté backend dans
|
||||
* une seule transaction.
|
||||
*/
|
||||
deleteCampaign(): void {
|
||||
if (!this.campaign) return;
|
||||
if (this.arcs.length > 0) {
|
||||
alert(
|
||||
`Impossible de supprimer "${this.campaign.name}" : elle contient encore ${this.arcs.length} arc(s).\n` +
|
||||
`Videz la campagne (arcs et chapitres) avant de la supprimer.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Supprimer définitivement la campagne "${this.campaign.name}" ?`)) return;
|
||||
this.campaignService.deleteCampaign(this.campaign.id!).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns']),
|
||||
error: () => console.error('Erreur lors de la suppression de la campagne')
|
||||
const campaign = this.campaign;
|
||||
this.campaignService.getCampaignDeletionImpact(campaign.id!).subscribe({
|
||||
next: impact => {
|
||||
const parts: string[] = [];
|
||||
if (impact.arcs > 0) parts.push(`${impact.arcs} arc${impact.arcs > 1 ? 's' : ''}`);
|
||||
if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`);
|
||||
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
|
||||
if (impact.characters > 0) parts.push(`${impact.characters} personnage${impact.characters > 1 ? 's' : ''}`);
|
||||
|
||||
const lines = [`Supprimer définitivement la campagne "${campaign.name}" ?`];
|
||||
if (parts.length) {
|
||||
lines.push('');
|
||||
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Cette action est irréversible.');
|
||||
|
||||
if (!confirm(lines.join('\n'))) return;
|
||||
this.campaignService.deleteCampaign(campaign.id!).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns']),
|
||||
error: () => console.error('Erreur lors de la suppression de la campagne')
|
||||
});
|
||||
},
|
||||
error: () => console.error('Impossible de récupérer les dépendances de la campagne')
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Observable, forkJoin, of } from 'rxjs';
|
||||
import { switchMap, map } from 'rxjs/operators';
|
||||
import { CampaignService } from '../services/campaign.service';
|
||||
import { CharacterService } from '../services/character.service';
|
||||
import { TreeItem } from '../services/layout.service';
|
||||
import { Arc, Chapter, Scene } from '../services/campaign.model';
|
||||
import { Character } from '../services/character.model';
|
||||
|
||||
/**
|
||||
* Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes)
|
||||
@@ -16,16 +18,21 @@ export interface CampaignTreeData {
|
||||
arcs: Arc[];
|
||||
chaptersByArc: Record<string, Chapter[]>;
|
||||
scenesByChapter: Record<string, Scene[]>;
|
||||
characters: Character[];
|
||||
}
|
||||
|
||||
export function loadCampaignTreeData(
|
||||
service: CampaignService,
|
||||
campaignId: string
|
||||
campaignId: string,
|
||||
characterService: CharacterService
|
||||
): Observable<CampaignTreeData> {
|
||||
return service.getArcs(campaignId).pipe(
|
||||
switchMap(arcs => {
|
||||
return forkJoin({
|
||||
arcs: service.getArcs(campaignId),
|
||||
characters: characterService.getByCampaign(campaignId)
|
||||
}).pipe(
|
||||
switchMap(({ arcs, characters }) => {
|
||||
if (arcs.length === 0) {
|
||||
return of({ arcs, chaptersByArc: {}, scenesByChapter: {} });
|
||||
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters });
|
||||
}
|
||||
const chapterCalls = arcs.map(a =>
|
||||
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
|
||||
@@ -40,7 +47,7 @@ export function loadCampaignTreeData(
|
||||
});
|
||||
|
||||
if (allChapters.length === 0) {
|
||||
return of({ arcs, chaptersByArc, scenesByChapter: {} });
|
||||
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters });
|
||||
}
|
||||
const sceneCalls = allChapters.map(c =>
|
||||
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
|
||||
@@ -49,7 +56,7 @@ export function loadCampaignTreeData(
|
||||
map(sceneResults => {
|
||||
const scenesByChapter: Record<string, Scene[]> = {};
|
||||
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
|
||||
return { arcs, chaptersByArc, scenesByChapter };
|
||||
return { arcs, chaptersByArc, scenesByChapter, characters };
|
||||
})
|
||||
);
|
||||
})
|
||||
@@ -67,9 +74,33 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
||||
// IDs préfixés par type pour éviter les collisions dans LayoutService.expanded
|
||||
// (chaque entité a sa propre séquence IDENTITY en base → arc.id=1 et chapter.id=1
|
||||
// peuvent coexister et se marchaient sur les pieds dans le Set<string> global).
|
||||
const sortedCharacters = [...data.characters].sort(byName);
|
||||
const characterItems: TreeItem[] = sortedCharacters.map(ch => ({
|
||||
id: `character-${ch.id}`,
|
||||
label: ch.name,
|
||||
route: `/campaigns/${campaignId}/characters/${ch.id}/edit`
|
||||
}));
|
||||
|
||||
const charactersNode: TreeItem = {
|
||||
id: 'characters-root',
|
||||
label: 'Personnages',
|
||||
iconKey: 'users',
|
||||
children: characterItems,
|
||||
meta: characterItems.length ? String(characterItems.length) : undefined,
|
||||
sectionHeaderBefore: 'Personnages',
|
||||
// Note : si pas d'arcs, le filet au-dessus de "Personnages" est masqué par CSS
|
||||
// (:first-child), ce qui est voulu — on ne veut pas de ligne seule en haut.
|
||||
createActions: [{
|
||||
id: 'new-character',
|
||||
label: 'Nouveau PJ',
|
||||
route: `/campaigns/${campaignId}/characters/create`,
|
||||
actionIcon: 'plus'
|
||||
}]
|
||||
};
|
||||
|
||||
const sortedArcs = [...data.arcs].sort(byName);
|
||||
|
||||
return sortedArcs.map(arc => {
|
||||
const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => {
|
||||
const sortedChapters = [...(data.chaptersByArc[arc.id!] ?? [])].sort(byName);
|
||||
|
||||
const chapterItems: TreeItem[] = sortedChapters.map(ch => {
|
||||
@@ -98,6 +129,8 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
||||
label: arc.name,
|
||||
children: chapterItems,
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}`,
|
||||
sectionHeaderBefore: idx === 0 ? 'Narration' : undefined,
|
||||
|
||||
createActions: [{
|
||||
id: `new-chapter-${arc.id}`,
|
||||
label: 'Nouveau chapitre',
|
||||
@@ -106,4 +139,6 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
||||
}]
|
||||
};
|
||||
});
|
||||
|
||||
return [...arcNodes, charactersNode];
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="chapter-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom du chapitre *</label>
|
||||
<label for="chapter-create-name">Nom du chapitre *</label>
|
||||
<input
|
||||
id="chapter-create-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: Chapitre 1: Les Disparitions"
|
||||
@@ -18,8 +19,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<label for="chapter-create-description">Description</label>
|
||||
<textarea
|
||||
id="chapter-create-description"
|
||||
formControlName="description"
|
||||
placeholder="Décrivez ce chapitre..."
|
||||
rows="5">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { Campaign } from '../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
@@ -32,6 +33,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private layoutService: LayoutService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
@@ -50,7 +52,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
|
||||
this.arcName = currentArc?.name ?? '';
|
||||
|
||||
@@ -43,8 +43,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Titre du chapitre *</label>
|
||||
<label for="chapter-edit-name">Titre du chapitre *</label>
|
||||
<input
|
||||
id="chapter-edit-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: Chapitre 1: Les Disparitions"
|
||||
@@ -53,8 +54,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Synopsis du chapitre</label>
|
||||
<label for="chapter-edit-description">Synopsis du chapitre</label>
|
||||
<textarea
|
||||
id="chapter-edit-description"
|
||||
formControlName="description"
|
||||
placeholder="Décrivez brièvement ce qui se passe dans ce chapitre..."
|
||||
rows="5">
|
||||
@@ -62,8 +64,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Notes du Maître de Jeu</label>
|
||||
<label for="chapter-edit-gm-notes">Notes du Maître de Jeu</label>
|
||||
<textarea
|
||||
id="chapter-edit-gm-notes"
|
||||
formControlName="gmNotes"
|
||||
placeholder="Vos notes privées sur le déroulement du chapitre, les événements clés, les rebondissements..."
|
||||
rows="6">
|
||||
@@ -73,16 +76,18 @@
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Objectifs des joueurs</label>
|
||||
<label for="chapter-edit-player-objectives">Objectifs des joueurs</label>
|
||||
<textarea
|
||||
id="chapter-edit-player-objectives"
|
||||
formControlName="playerObjectives"
|
||||
placeholder="Que doivent accomplir les joueurs dans ce chapitre ?"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Enjeux narratifs</label>
|
||||
<label for="chapter-edit-narrative-stakes">Enjeux narratifs</label>
|
||||
<textarea
|
||||
id="chapter-edit-narrative-stakes"
|
||||
formControlName="narrativeStakes"
|
||||
placeholder="Quels sont les enjeux dramatiques ?"
|
||||
rows="4">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
@@ -61,6 +62,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
@@ -98,7 +100,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Chapter, Scene } from '../../services/campaign.model';
|
||||
@@ -48,6 +49,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {}
|
||||
@@ -67,7 +69,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||
scenes: this.campaignService.getScenes(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
|
||||
this.chapter = chapter;
|
||||
this.scenes = scenes;
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
Modifier
|
||||
</button>
|
||||
<button type="button" class="btn-danger" (click)="deleteChapter()" title="Supprimer le chapitre et ses scènes">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Pencil, Network } from 'lucide-angular';
|
||||
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
@@ -27,6 +28,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
|
||||
export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
readonly Pencil = Pencil;
|
||||
readonly Network = Network;
|
||||
readonly Trash2 = Trash2;
|
||||
|
||||
campaignId = '';
|
||||
arcId = '';
|
||||
@@ -40,6 +42,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
@@ -66,7 +69,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
@@ -112,6 +115,33 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppression en cascade : récupère le compte de scènes qui tomberont avec
|
||||
* le chapitre, l'annonce dans la confirmation, puis délègue au backend.
|
||||
*/
|
||||
deleteChapter(): void {
|
||||
if (!this.chapter) return;
|
||||
const chapter = this.chapter;
|
||||
this.campaignService.getChapterDeletionImpact(chapter.id!).subscribe({
|
||||
next: impact => {
|
||||
const lines = [`Supprimer le chapitre "${chapter.name}" ?`];
|
||||
if (impact.scenes > 0) {
|
||||
lines.push('');
|
||||
lines.push(`Cette action supprimera aussi : ${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}.`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Cette action est irréversible.');
|
||||
|
||||
if (!confirm(lines.join('\n'))) return;
|
||||
this.campaignService.deleteChapter(chapter.id!).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
|
||||
error: () => console.error('Erreur lors de la suppression du chapitre')
|
||||
});
|
||||
},
|
||||
error: () => console.error('Impossible de récupérer les dépendances du chapitre')
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="scene-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom de la scène *</label>
|
||||
<label for="scene-create-name">Nom de la scène *</label>
|
||||
<input
|
||||
id="scene-create-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: Arrivée au village"
|
||||
@@ -18,8 +19,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<label for="scene-create-description">Description</label>
|
||||
<textarea
|
||||
id="scene-create-description"
|
||||
formControlName="description"
|
||||
placeholder="Décrivez la scène, les événements clés, les PNJ présents..."
|
||||
rows="6">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { Campaign } from '../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
@@ -33,6 +34,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private layoutService: LayoutService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
@@ -52,7 +54,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
|
||||
this.chapterName = currentChapter?.name ?? '';
|
||||
|
||||
@@ -43,8 +43,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Titre de la scène *</label>
|
||||
<label for="scene-edit-name">Titre de la scène *</label>
|
||||
<input
|
||||
id="scene-edit-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: Arrivée au village"
|
||||
@@ -53,8 +54,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description courte *</label>
|
||||
<label for="scene-edit-description">Description courte *</label>
|
||||
<textarea
|
||||
id="scene-edit-description"
|
||||
formControlName="description"
|
||||
placeholder="Résumé en une ou deux phrases de ce qui se passe..."
|
||||
rows="3">
|
||||
@@ -65,17 +67,18 @@
|
||||
<app-expandable-section title="Contexte et ambiance" icon="📍" [initiallyOpen]="true">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Lieu</label>
|
||||
<input type="text" formControlName="location" placeholder="Ex: Taverne du Dragon d'Or" />
|
||||
<label for="scene-edit-location">Lieu</label>
|
||||
<input id="scene-edit-location" type="text" formControlName="location" placeholder="Ex: Taverne du Dragon d'Or" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Moment</label>
|
||||
<input type="text" formControlName="timing" placeholder="Ex: Soir, à la tombée de la nuit" />
|
||||
<label for="scene-edit-timing">Moment</label>
|
||||
<input id="scene-edit-timing" type="text" formControlName="timing" placeholder="Ex: Soir, à la tombée de la nuit" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ambiance et atmosphère</label>
|
||||
<label for="scene-edit-atmosphere">Ambiance et atmosphère</label>
|
||||
<textarea
|
||||
id="scene-edit-atmosphere"
|
||||
formControlName="atmosphere"
|
||||
placeholder="Décrivez l'ambiance générale de la scène (sons, odeurs, lumière, émotions...)"
|
||||
rows="4">
|
||||
@@ -179,12 +182,13 @@
|
||||
<!-- Section : Combat ou rencontre -->
|
||||
<app-expandable-section title="Combat ou rencontre" icon="⚔️">
|
||||
<div class="field">
|
||||
<label>Difficulté estimée</label>
|
||||
<input type="text" formControlName="combatDifficulty" placeholder="Ex: Moyenne, 3 gobelins niveau 2" />
|
||||
<label for="scene-edit-combat-difficulty">Difficulté estimée</label>
|
||||
<input id="scene-edit-combat-difficulty" type="text" formControlName="combatDifficulty" placeholder="Ex: Moyenne, 3 gobelins niveau 2" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ennemis et créatures</label>
|
||||
<label for="scene-edit-enemies">Ennemis et créatures</label>
|
||||
<textarea
|
||||
id="scene-edit-enemies"
|
||||
formControlName="enemies"
|
||||
placeholder="Liste des ennemis présents dans cette scène..."
|
||||
rows="4">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
@@ -65,6 +66,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
@@ -116,7 +118,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
scene: this.campaignService.getSceneById(this.sceneId),
|
||||
chapterScenes: this.campaignService.getScenes(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
Modifier
|
||||
</button>
|
||||
<button type="button" class="btn-danger" (click)="deleteScene()" title="Supprimer la scène">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Pencil } from 'lucide-angular';
|
||||
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
@@ -26,6 +27,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
|
||||
})
|
||||
export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
readonly Pencil = Pencil;
|
||||
readonly Trash2 = Trash2;
|
||||
|
||||
campaignId = '';
|
||||
arcId = '';
|
||||
@@ -40,6 +42,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
@@ -69,7 +72,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
scene: this.campaignService.getSceneById(this.sceneId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
@@ -110,6 +113,19 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
]);
|
||||
}
|
||||
|
||||
/** Suppression simple — une scène n'a pas d'enfants. Retour au chapitre parent. */
|
||||
deleteScene(): void {
|
||||
if (!this.scene) return;
|
||||
const scene = this.scene;
|
||||
if (!confirm(`Supprimer la scène "${scene.name}" ?\n\nCette action est irréversible.`)) return;
|
||||
this.campaignService.deleteScene(scene.id!).subscribe({
|
||||
next: () => this.router.navigate([
|
||||
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId
|
||||
]),
|
||||
error: () => console.error('Erreur lors de la suppression de la scène')
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
|
||||
17
web/src/app/guards/demo-mode.guard.ts
Normal file
17
web/src/app/guards/demo-mode.guard.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { ConfigService } from '../services/config.service';
|
||||
|
||||
/**
|
||||
* Bloque l'acces aux routes sensibles quand demoMode est actif et redirige
|
||||
* vers la home. Defense UX ; le verrou serveur reste la source de verite.
|
||||
*/
|
||||
export const hiddenInDemoGuard: CanActivateFn = () => {
|
||||
const config = inject(ConfigService);
|
||||
const router = inject(Router);
|
||||
if (config.demoMode) {
|
||||
router.navigate(['/']);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
85
web/src/app/lore/folder-view/folder-view.component.html
Normal file
85
web/src/app/lore/folder-view/folder-view.component.html
Normal file
@@ -0,0 +1,85 @@
|
||||
<div class="folder-view" *ngIf="node">
|
||||
|
||||
<!-- Fil d'Ariane : Lore → ancêtres → dossier courant -->
|
||||
<nav class="breadcrumb" aria-label="Fil d'Ariane">
|
||||
<button type="button" class="crumb" (click)="navigateToLoreRoot()" *ngIf="lore">
|
||||
{{ lore.name }}
|
||||
</button>
|
||||
<ng-container *ngFor="let ancestor of ancestors">
|
||||
<lucide-icon [img]="ChevronRight" [size]="12" class="crumb-sep"></lucide-icon>
|
||||
<button type="button" class="crumb" (click)="navigateToSubfolder(ancestor.id!)">
|
||||
{{ ancestor.name }}
|
||||
</button>
|
||||
</ng-container>
|
||||
<lucide-icon [img]="ChevronRight" [size]="12" class="crumb-sep"></lucide-icon>
|
||||
<span class="crumb current">{{ node.name }}</span>
|
||||
</nav>
|
||||
|
||||
<!-- Header : icône + nom + actions -->
|
||||
<div class="detail-header">
|
||||
<div class="header-texts">
|
||||
<h1>
|
||||
<lucide-icon [img]="folderIcon" [size]="24" class="title-icon"></lucide-icon>
|
||||
{{ node.name }}
|
||||
</h1>
|
||||
<p class="description">
|
||||
{{ subfolders.length }} sous-dossier(s) · {{ pages.length }} page(s)
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="navigateToEdit()" title="Modifier le dossier">
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
Modifier
|
||||
</button>
|
||||
<button type="button" class="btn-danger" (click)="delete()" title="Supprimer le dossier et tout son contenu">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sous-dossiers -->
|
||||
<section class="detail-section">
|
||||
<div class="section-header">
|
||||
<h2>Sous-dossiers</h2>
|
||||
<button class="btn-add" (click)="navigateToCreateSubfolder()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouveau sous-dossier
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="items-grid" *ngIf="subfolders.length > 0">
|
||||
<div class="node-card" *ngFor="let sub of subfolders" (click)="navigateToSubfolder(sub.id!)">
|
||||
<lucide-icon [img]="Folder" [size]="24" class="node-icon"></lucide-icon>
|
||||
<span class="node-name">{{ sub.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" *ngIf="subfolders.length === 0">
|
||||
<p>Aucun sous-dossier.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pages -->
|
||||
<section class="detail-section">
|
||||
<div class="section-header">
|
||||
<h2>Pages</h2>
|
||||
<button class="btn-add" (click)="navigateToCreatePage()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouvelle page
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="items-grid" *ngIf="pages.length > 0">
|
||||
<div class="node-card" *ngFor="let page of pages" (click)="navigateToPage(page.id!)">
|
||||
<lucide-icon [img]="FileText" [size]="24" class="node-icon"></lucide-icon>
|
||||
<span class="node-name">{{ page.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" *ngIf="pages.length === 0">
|
||||
<p>Aucune page dans ce dossier.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
154
web/src/app/lore/folder-view/folder-view.component.scss
Normal file
154
web/src/app/lore/folder-view/folder-view.component.scss
Normal file
@@ -0,0 +1,154 @@
|
||||
.folder-view {
|
||||
padding: 2.5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
|
||||
.crumb {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
|
||||
&:hover { color: #c7d2fe; background: #1f2937; }
|
||||
|
||||
&.current {
|
||||
color: #e5e7eb;
|
||||
font-weight: 500;
|
||||
cursor: default;
|
||||
}
|
||||
&.current:hover { background: transparent; }
|
||||
}
|
||||
|
||||
.crumb-sep { color: #4b5563; flex-shrink: 0; }
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
// Sticky pour que Modifier/Supprimer restent accessibles même en scrollant
|
||||
// une longue liste de sous-dossiers/pages.
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: #0a0a14;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
|
||||
.header-texts { flex: 1; min-width: 0; }
|
||||
|
||||
h1 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.title-icon { color: #6c63ff; }
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #6b7280;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary, .btn-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-secondary { background: #1f2937; color: #d1d5db; &:hover { background: #374151; } }
|
||||
.btn-danger { background: #3a1e1e; color: #f87171; &:hover { background: #5a2e2e; } }
|
||||
|
||||
.detail-section {
|
||||
background: #0d1117;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem 1.75rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
|
||||
h2 { color: #d1d5db; font-size: 1rem; font-weight: 600; }
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: #5b52e0; }
|
||||
}
|
||||
|
||||
.items-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.node-card {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.2s;
|
||||
|
||||
&:hover { border-color: #6c63ff; transform: translateY(-2px); }
|
||||
|
||||
.node-icon { color: #6c63ff; }
|
||||
.node-name { color: white; font-size: 0.9rem; font-weight: 600; }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
179
web/src/app/lore/folder-view/folder-view.component.ts
Normal file
179
web/src/app/lore/folder-view/folder-view.component.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, LucideIconData, Folder, FileText, Pencil, Trash2, Plus, ChevronRight } 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 { Lore, LoreNode } from '../../services/lore.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { resolveIcon } from '../lore-icons';
|
||||
|
||||
/**
|
||||
* Vue "détail" d'un dossier : affiche son contenu (sous-dossiers + pages) et
|
||||
* expose les actions Modifier / Supprimer dans le header.
|
||||
*
|
||||
* L'édition du nom/icône/parent se fait dans l'écran séparé folder-edit
|
||||
* (/folders/:folderId/edit). La suppression avec cascade déclenche le même
|
||||
* dialogue d'impact que les autres écrans.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-folder-view',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LucideAngularModule],
|
||||
templateUrl: './folder-view.component.html',
|
||||
styleUrls: ['./folder-view.component.scss']
|
||||
})
|
||||
export class FolderViewComponent implements OnInit, OnDestroy {
|
||||
readonly Folder = Folder;
|
||||
readonly FileText = FileText;
|
||||
readonly Pencil = Pencil;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Plus = Plus;
|
||||
readonly ChevronRight = ChevronRight;
|
||||
|
||||
loreId = '';
|
||||
folderId = '';
|
||||
lore: Lore | null = null;
|
||||
node: LoreNode | null = null;
|
||||
subfolders: LoreNode[] = [];
|
||||
pages: Page[] = [];
|
||||
/** Chaîne des dossiers ancêtres (du plus proche du racine vers le parent direct). */
|
||||
ancestors: LoreNode[] = [];
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private loreService: LoreService,
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
// Réagit aux changements de :folderId pour que la navigation d'un dossier
|
||||
// à un autre via la sidebar ne démonte/remonte pas le composant à blanc.
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const next = pm.get('folderId')!;
|
||||
if (next !== this.folderId) {
|
||||
this.folderId = next;
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
this.folderId = this.route.snapshot.paramMap.get('folderId')!;
|
||||
this.load();
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
forkJoin({
|
||||
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
|
||||
node: this.loreService.getLoreNodeById(this.folderId)
|
||||
}).subscribe(({ sidebar, node }) => {
|
||||
this.layoutService.show(buildLoreSidebarConfig(sidebar));
|
||||
this.lore = sidebar.lore;
|
||||
this.node = node;
|
||||
this.pageTitleService.set(node.name);
|
||||
this.subfolders = sidebar.nodes.filter(n => n.parentId === this.folderId);
|
||||
this.pages = sidebar.pages.filter(p => p.nodeId === this.folderId);
|
||||
this.ancestors = this.buildAncestors(node, sidebar.nodes);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remonte la chaîne parentId → parent en partant du dossier courant,
|
||||
* sans s'inclure soi-même. Ordre : racine → parent direct.
|
||||
* Garde-fou sur la longueur au cas où une boucle existerait en BDD
|
||||
* (ne devrait pas, mais ceinture+bretelles).
|
||||
*/
|
||||
private buildAncestors(current: LoreNode, allNodes: LoreNode[]): LoreNode[] {
|
||||
const byId = new Map(allNodes.map(n => [n.id!, n]));
|
||||
const chain: LoreNode[] = [];
|
||||
const seen = new Set<string>();
|
||||
let parentId = current.parentId ?? null;
|
||||
while (parentId && !seen.has(parentId) && chain.length < 32) {
|
||||
const parent = byId.get(parentId);
|
||||
if (!parent) break;
|
||||
chain.push(parent);
|
||||
seen.add(parent.id!);
|
||||
parentId = parent.parentId ?? null;
|
||||
}
|
||||
return chain.reverse();
|
||||
}
|
||||
|
||||
/** Icône du dossier courant, résolue depuis la clé lucide stockée sur le node. */
|
||||
get folderIcon(): LucideIconData {
|
||||
return resolveIcon(this.node?.icon ?? null);
|
||||
}
|
||||
|
||||
navigateToSubfolder(id: string): void {
|
||||
this.router.navigate(['/lore', this.loreId, 'folders', id]);
|
||||
}
|
||||
|
||||
navigateToLoreRoot(): void {
|
||||
this.router.navigate(['/lore', this.loreId]);
|
||||
}
|
||||
|
||||
navigateToPage(id: string): void {
|
||||
this.router.navigate(['/lore', this.loreId, 'pages', id]);
|
||||
}
|
||||
|
||||
navigateToCreateSubfolder(): void {
|
||||
this.router.navigate(['/lore', this.loreId, 'folders', this.folderId, 'create']);
|
||||
}
|
||||
|
||||
navigateToCreatePage(): void {
|
||||
this.router.navigate(['/lore', this.loreId, 'nodes', this.folderId, 'pages', 'create']);
|
||||
}
|
||||
|
||||
navigateToEdit(): void {
|
||||
this.router.navigate(['/lore', this.loreId, 'folders', this.folderId, 'edit']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppression en cascade avec dialogue d'impact. On délègue au backend (transaction
|
||||
* atomique), et au retour on remonte soit au dossier parent soit au Lore racine.
|
||||
*/
|
||||
delete(): void {
|
||||
if (!this.node) return;
|
||||
const node = this.node;
|
||||
this.loreService.getLoreNodeDeletionImpact(this.folderId).subscribe({
|
||||
next: impact => {
|
||||
const parts: string[] = [];
|
||||
if (impact.folders > 0) parts.push(`${impact.folders} sous-dossier${impact.folders > 1 ? 's' : ''}`);
|
||||
if (impact.pages > 0) parts.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
|
||||
|
||||
const lines = [`Supprimer le dossier "${node.name}" ?`];
|
||||
if (parts.length) {
|
||||
lines.push('');
|
||||
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Cette action est irréversible.');
|
||||
|
||||
if (!confirm(lines.join('\n'))) return;
|
||||
this.loreService.deleteLoreNode(this.folderId).subscribe({
|
||||
next: () => {
|
||||
// Remonte au dossier parent si présent, sinon au Lore.
|
||||
if (node.parentId) {
|
||||
this.router.navigate(['/lore', this.loreId, 'folders', node.parentId]);
|
||||
} else {
|
||||
this.router.navigate(['/lore', this.loreId]);
|
||||
}
|
||||
},
|
||||
error: () => console.error('Erreur lors de la suppression du dossier')
|
||||
});
|
||||
},
|
||||
error: () => console.error('Impossible de récupérer les dépendances du dossier')
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,9 @@
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom de l'univers *</label>
|
||||
<label for="lore-name">Nom de l'univers *</label>
|
||||
<input
|
||||
id="lore-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: Royaume des Ombres, Cyberpunk 2157..."
|
||||
@@ -21,8 +22,9 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<label for="lore-description">Description</label>
|
||||
<textarea
|
||||
id="lore-description"
|
||||
formControlName="description"
|
||||
placeholder="Décrivez brièvement votre univers, son ambiance, son genre..."
|
||||
rows="5"
|
||||
|
||||
@@ -21,12 +21,12 @@
|
||||
<!-- ============ Header : mode édition inline ============ -->
|
||||
<div class="detail-header edit-mode" *ngIf="editing">
|
||||
<div class="field">
|
||||
<label>Nom</label>
|
||||
<input type="text" [(ngModel)]="editName" name="editName" required />
|
||||
<label for="lore-detail-edit-name">Nom</label>
|
||||
<input id="lore-detail-edit-name" type="text" [(ngModel)]="editName" name="editName" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
|
||||
<label for="lore-detail-edit-description">Description</label>
|
||||
<textarea id="lore-detail-edit-description" [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ============ Grille des dossiers racine ============ -->
|
||||
<section class="detail-section nodes-section">
|
||||
<section class="detail-section nodes-section" *ngIf="!editing">
|
||||
<div class="section-header">
|
||||
<h2>Dossiers</h2>
|
||||
<button class="btn-add" (click)="navigateToCreateNode()">
|
||||
|
||||
@@ -15,11 +15,19 @@
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
// Sticky : les actions Modifier/Supprimer du Lore restent accessibles
|
||||
// quand on scrolle la grille de dossiers.
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: #0a0a14;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
.header-texts { flex: 1; min-width: 0; }
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
navigateToFolder(nodeId: string): void {
|
||||
this.router.navigate(['/lore', this.lore!.id, 'folders', nodeId, 'edit']);
|
||||
this.router.navigate(['/lore', this.lore!.id, 'folders', nodeId]);
|
||||
}
|
||||
|
||||
// ─────────────── Édition / suppression du Lore ───────────────
|
||||
@@ -110,24 +110,43 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppression protégée : refus si le Lore contient encore des dossiers
|
||||
* ou des pages. Protège contre un clic accidentel sur des données
|
||||
* construites longuement. Logique côté frontend (pas d'appel HTTP
|
||||
* supplémentaire) car les données sont déjà chargées.
|
||||
* Suppression en cascade : récupère le détail de ce qui tombera (dossiers,
|
||||
* pages, templates) et de ce qui sera détaché (campagnes conservées mais
|
||||
* sans lien vers ce Lore), affiche le récapitulatif dans la confirmation,
|
||||
* puis délègue au backend (transaction atomique).
|
||||
*/
|
||||
deleteLore(): void {
|
||||
if (!this.lore) return;
|
||||
if (this.allNodes.length > 0) {
|
||||
alert(
|
||||
`Impossible de supprimer "${this.lore.name}" : il contient encore ${this.allNodes.length} dossier(s).\n` +
|
||||
`Videz le Lore (dossiers et pages) avant de le supprimer.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Supprimer définitivement le Lore "${this.lore.name}" ?`)) return;
|
||||
this.loreService.deleteLore(this.lore.id!).subscribe({
|
||||
next: () => this.router.navigate(['/lore']),
|
||||
error: () => console.error('Erreur lors de la suppression du Lore')
|
||||
const lore = this.lore;
|
||||
this.loreService.getLoreDeletionImpact(lore.id!).subscribe({
|
||||
next: impact => {
|
||||
const deleted: string[] = [];
|
||||
if (impact.folders > 0) deleted.push(`${impact.folders} dossier${impact.folders > 1 ? 's' : ''}`);
|
||||
if (impact.pages > 0) deleted.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
|
||||
if (impact.templates > 0) deleted.push(`${impact.templates} template${impact.templates > 1 ? 's' : ''}`);
|
||||
|
||||
const lines = [`Supprimer définitivement le Lore "${lore.name}" ?`];
|
||||
if (deleted.length) {
|
||||
lines.push('');
|
||||
lines.push(`Cette action supprimera aussi : ${deleted.join(', ')}.`);
|
||||
}
|
||||
if (impact.detachedCampaigns > 0) {
|
||||
lines.push('');
|
||||
lines.push(
|
||||
`${impact.detachedCampaigns} campagne${impact.detachedCampaigns > 1 ? 's' : ''} ${impact.detachedCampaigns > 1 ? 'seront conservées' : 'sera conservée'} ` +
|
||||
`mais ${impact.detachedCampaigns > 1 ? 'perdront' : 'perdra'} leur lien vers cet univers.`
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Cette action est irréversible.');
|
||||
|
||||
if (!confirm(lines.join('\n'))) return;
|
||||
this.loreService.deleteLore(lore.id!).subscribe({
|
||||
next: () => this.router.navigate(['/lore']),
|
||||
error: () => console.error('Erreur lors de la suppression du Lore')
|
||||
});
|
||||
},
|
||||
error: () => console.error('Impossible de récupérer les dépendances du Lore')
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -49,15 +49,6 @@
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Adresse</label>
|
||||
<input
|
||||
type="text"
|
||||
formControlName="address"
|
||||
placeholder="nom-du-dossier"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
<lucide-icon [img]="getIcon(selectedIcon)" [size]="16"></lucide-icon>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user