errorFactory) {
+ try {
+ flux
+ .timeout(Duration.ofSeconds(timeoutSeconds))
+ .doOnNext(handler)
+ .blockLast();
+ // Flux terminé sans event done/error (ex: connexion coupée) → on signale.
+ if (!terminated[0]) {
+ onError.accept(errorFactory.apply(
+ "Le flux d'import s'est interrompu avant la fin.", null));
+ }
+ } catch (Exception e) {
+ if (!terminated[0]) {
+ String cause = e.getClass().getSimpleName()
+ + (e.getMessage() != null ? " — " + e.getMessage() : "");
+ onError.accept(errorFactory.apply(
+ "Erreur lors du streaming d'import depuis le Brain : " + cause, e));
+ }
+ }
+ }
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/IdRemapper.java b/core/src/main/java/com/loremind/infrastructure/transfer/IdRemapper.java
new file mode 100644
index 0000000..93194ca
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/transfer/IdRemapper.java
@@ -0,0 +1,81 @@
+package com.loremind.infrastructure.transfer;
+
+import com.loremind.domain.campaigncontext.ArcType;
+import com.loremind.domain.campaigncontext.Prerequisite;
+import com.loremind.domain.campaigncontext.SceneBranch;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Logique de remapping des identifiants pour l'import en mode FUSION
+ * (cf. {@link ImportService}).
+ *
+ * Fonctions PURES (sans état ni I/O) : chaque entité importée reçoit un nouvel id,
+ * et toutes les références — FK {@code Long} comme refs faibles stockées en
+ * {@code String} — sont réécrites {@code oldId → newId} via les maps fournies.
+ * Une référence absente de la map est CONSERVÉE telle quelle (choix : ne jamais
+ * perdre d'info, ne jamais planter sur une référence hors périmètre d'export).
+ */
+final class IdRemapper {
+
+ private IdRemapper() {
+ }
+
+ /** Remap d'une FK Long : si absente de la map, on garde l'ancienne valeur ; {@code null → null}. */
+ static Long remapId(Map map, Long oldId) {
+ if (oldId == null) return null;
+ return map.getOrDefault(oldId, oldId);
+ }
+
+ /** Remap d'un id stocké en String ({@code "oldLong" → "newLong"}) via une map Long. */
+ static String remapStringId(Map map, String oldId) {
+ if (oldId == null || oldId.isBlank()) return oldId;
+ try {
+ Long newId = map.get(Long.parseLong(oldId.trim()));
+ return newId != null ? String.valueOf(newId) : oldId;
+ } catch (NumberFormatException ex) {
+ return oldId; // pas un Long : on laisse tel quel
+ }
+ }
+
+ static List remapStringList(Map map, List ids) {
+ if (ids == null) return null;
+ List out = new ArrayList<>(ids.size());
+ for (String id : ids) out.add(remapStringId(map, id));
+ return out;
+ }
+
+ static List remapPrerequisites(Map chapterMap, List prereqs) {
+ if (prereqs == null) return null;
+ List out = new ArrayList<>(prereqs.size());
+ for (Prerequisite p : prereqs) {
+ if (p instanceof Prerequisite.QuestCompleted qc) {
+ out.add(new Prerequisite.QuestCompleted(remapStringId(chapterMap, qc.questId())));
+ } else {
+ out.add(p); // FlagSet / SessionReached : inchangés
+ }
+ }
+ return out;
+ }
+
+ static List remapBranches(Map sceneMap, List branches) {
+ if (branches == null) return null;
+ List out = new ArrayList<>(branches.size());
+ for (SceneBranch b : branches) {
+ out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition()));
+ }
+ return out;
+ }
+
+ /** Parse un {@link ArcType} tolérant : type inconnu ou {@code null} → {@code LINEAR}. */
+ static ArcType parseArcType(String type) {
+ if (type == null) return ArcType.LINEAR;
+ try {
+ return ArcType.valueOf(type);
+ } catch (IllegalArgumentException ex) {
+ return ArcType.LINEAR;
+ }
+ }
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ImageImporter.java b/core/src/main/java/com/loremind/infrastructure/transfer/ImageImporter.java
new file mode 100644
index 0000000..ff290f9
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/transfer/ImageImporter.java
@@ -0,0 +1,91 @@
+package com.loremind.infrastructure.transfer;
+
+import com.loremind.domain.images.ports.ImageStorage;
+import com.loremind.infrastructure.persistence.entity.ImageJpaEntity;
+import com.loremind.infrastructure.persistence.jpa.ImageJpaRepository;
+import com.loremind.infrastructure.transfer.dto.ContentExport;
+import org.springframework.stereotype.Component;
+
+import java.io.ByteArrayInputStream;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Réécriture des images lors d'un import (cf. {@link ImportService}).
+ *
+ * Les binaires sont stockés sous LEUR CLÉ D'ORIGINE (pas de remapping de clé) :
+ * une image dont la clé existe déjà est RÉUTILISÉE (pas de réupload), pour éviter
+ * les doublons quand on agrège plusieurs exports dans la même base.
+ */
+@Component
+class ImageImporter {
+
+ private final ImageJpaRepository imageRepo;
+ private final ImageStorage imageStorage;
+
+ ImageImporter(ImageJpaRepository imageRepo, ImageStorage imageStorage) {
+ this.imageRepo = imageRepo;
+ this.imageStorage = imageStorage;
+ }
+
+ /**
+ * Réécrit les binaires d'images (clé préservée) et leurs métadonnées.
+ *
+ * @param export contenu importé (source des métadonnées par clé)
+ * @param imageBinaries {@code storageKey → binaire} lus depuis le zip
+ * @param result compteurs d'images (uploadées / réutilisées) à incrémenter
+ */
+ void importImages(ContentExport export,
+ Map imageBinaries,
+ ImportResult.Builder result) {
+ // Index des métadonnées d'image par clé (depuis le data.json).
+ Map metaByKey = new HashMap<>();
+ for (ContentExport.ImageDto img : nullSafe(export.images())) {
+ if (img.storageKey() != null) metaByKey.put(img.storageKey(), img);
+ }
+
+ for (Map.Entry bin : imageBinaries.entrySet()) {
+ String storageKey = bin.getKey();
+ byte[] data = bin.getValue();
+ if (imageRepo.findByStorageKey(storageKey).isPresent()) {
+ // Image déjà présente : on réutilise, pas de réupload (éviter doublon).
+ result.imageReused();
+ continue;
+ }
+ ContentExport.ImageDto meta = metaByKey.get(storageKey);
+ String contentType = meta != null && meta.contentType() != null
+ ? meta.contentType() : guessContentType(storageKey);
+ long size = meta != null ? meta.sizeBytes() : data.length;
+
+ imageStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length);
+
+ ImageJpaEntity e = new ImageJpaEntity();
+ e.setFilename(meta != null && meta.filename() != null
+ ? meta.filename() : fileNameOf(storageKey));
+ e.setContentType(contentType);
+ e.setSizeBytes(size);
+ e.setStorageKey(storageKey);
+ imageRepo.save(e);
+ result.imageUploaded();
+ }
+ }
+
+ private static List nullSafe(List list) {
+ return list != null ? list : List.of();
+ }
+
+ private static String fileNameOf(String storageKey) {
+ int slash = storageKey.lastIndexOf('/');
+ return slash >= 0 ? storageKey.substring(slash + 1) : storageKey;
+ }
+
+ private static String guessContentType(String storageKey) {
+ String lower = storageKey.toLowerCase();
+ if (lower.endsWith(".png")) return "image/png";
+ if (lower.endsWith(".gif")) return "image/gif";
+ if (lower.endsWith(".webp")) return "image/webp";
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
+ return "application/octet-stream";
+ }
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java
index 6f3b76b..60e1a45 100644
--- a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java
+++ b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java
@@ -1,9 +1,6 @@
package com.loremind.infrastructure.transfer;
import com.fasterxml.jackson.databind.ObjectMapper;
-import com.loremind.domain.campaigncontext.Prerequisite;
-import com.loremind.domain.campaigncontext.SceneBranch;
-import com.loremind.domain.images.ports.ImageStorage;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
import com.loremind.infrastructure.persistence.entity.*;
import com.loremind.infrastructure.persistence.jpa.*;
@@ -11,7 +8,6 @@ import com.loremind.infrastructure.transfer.dto.ContentExport;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
@@ -65,8 +61,7 @@ public class ImportService {
private final EnemyJpaRepository enemyRepo;
private final ItemCatalogJpaRepository itemCatalogRepo;
private final RandomTableJpaRepository randomTableRepo;
- private final ImageJpaRepository imageRepo;
- private final ImageStorage imageStorage;
+ private final ImageImporter imageImporter;
private final ObjectMapper objectMapper;
public ImportService(GameSystemJpaRepository gameSystemRepo,
@@ -83,8 +78,7 @@ public class ImportService {
EnemyJpaRepository enemyRepo,
ItemCatalogJpaRepository itemCatalogRepo,
RandomTableJpaRepository randomTableRepo,
- ImageJpaRepository imageRepo,
- ImageStorage imageStorage,
+ ImageImporter imageImporter,
ObjectMapper objectMapper) {
this.gameSystemRepo = gameSystemRepo;
this.loreRepo = loreRepo;
@@ -100,42 +94,20 @@ public class ImportService {
this.enemyRepo = enemyRepo;
this.itemCatalogRepo = itemCatalogRepo;
this.randomTableRepo = randomTableRepo;
- this.imageRepo = imageRepo;
- this.imageStorage = imageStorage;
+ this.imageImporter = imageImporter;
this.objectMapper = objectMapper;
}
@Transactional
public ImportResult importZip(InputStream zipStream) {
// 1. Parse du zip.
- ContentExport export = null;
- Map imageBinaries = new LinkedHashMap<>(); // storageKey -> binaire
- try (ZipInputStream zip = new ZipInputStream(zipStream)) {
- ZipEntry entry;
- while ((entry = zip.getNextEntry()) != null) {
- String name = entry.getName();
- if ("data.json".equals(name)) {
- export = objectMapper.readValue(readAll(zip), ContentExport.class);
- } else if (name.startsWith("images/") && !entry.isDirectory()) {
- // La cle de stockage est le chemin sans le prefixe "images/" du zip,
- // c'est-a-dire EXACTEMENT le storageKey d'origine ("images/UUID.ext").
- String storageKey = name.substring("images/".length());
- imageBinaries.put(storageKey, readAll(zip));
- }
- // manifest.json : ignore a l'import (info seulement).
- zip.closeEntry();
- }
- } catch (IOException e) {
- throw new UncheckedIOException("Echec de lecture du zip d'import", e);
- }
- if (export == null) {
- throw new IllegalArgumentException("Archive invalide : data.json introuvable");
- }
+ ParsedArchive archive = parseArchive(zipStream);
+ ContentExport export = archive.export();
ImportResult.Builder result = new ImportResult.Builder();
// 2. Reecriture des images (cle preservee).
- importImages(export, imageBinaries, result);
+ imageImporter.importImages(export, archive.imageBinaries(), result);
// 3. Insertion top-down + maps de remapping.
Map gameSystemMap = new HashMap<>();
@@ -184,7 +156,7 @@ public class ImportService {
e.setName(d.name());
e.setIcon(d.icon());
e.setParentId(d.parentId()); // remappe plus bas
- e.setLoreId(remapRequired(loreMap, d.loreId()));
+ e.setLoreId(IdRemapper.remapId(loreMap, d.loreId()));
LoreNodeJpaEntity saved = loreNodeRepo.save(e);
loreNodeMap.put(d.id(), saved.getId());
if (d.parentId() != null) loreNodesToFix.add(saved);
@@ -195,7 +167,7 @@ public class ImportService {
List templatesWithDefaultNode = new ArrayList<>();
for (ContentExport.TemplateDto d : nullSafe(export.templates())) {
TemplateJpaEntity e = new TemplateJpaEntity();
- e.setLoreId(remapRequired(loreMap, d.loreId()));
+ e.setLoreId(IdRemapper.remapId(loreMap, d.loreId()));
e.setName(d.name());
e.setDescription(d.description());
e.setDefaultNodeId(d.defaultNodeId()); // remappe plus bas
@@ -208,9 +180,9 @@ public class ImportService {
// -- Page (relatedPageIds remappe en 2e passe)
for (ContentExport.PageDto d : nullSafe(export.pages())) {
PageJpaEntity e = new PageJpaEntity();
- e.setLoreId(remapRequired(loreMap, d.loreId()));
- e.setNodeId(remapRequired(loreNodeMap, d.nodeId()));
- e.setTemplateId(remapNullable(templateMap, d.templateId()));
+ e.setLoreId(IdRemapper.remapId(loreMap, d.loreId()));
+ e.setNodeId(IdRemapper.remapId(loreNodeMap, d.nodeId()));
+ e.setTemplateId(IdRemapper.remapId(templateMap, d.templateId()));
e.setTitle(d.title());
e.setValues(d.values());
e.setImageValues(d.imageValues());
@@ -240,9 +212,9 @@ public class ImportService {
ArcJpaEntity e = new ArcJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
- e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
+ e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setOrder(d.order());
- e.setType(parseArcType(d.type()));
+ e.setType(IdRemapper.parseArcType(d.type()));
e.setIcon(d.icon());
e.setThemes(d.themes());
e.setStakes(d.stakes());
@@ -263,7 +235,7 @@ public class ImportService {
e.setName(d.name());
e.setDescription(d.description());
e.setIcon(d.icon());
- e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
+ e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setOrder(d.order());
List items = new ArrayList<>();
for (ContentExport.CatalogItemDto i : nullSafe(d.items())) {
@@ -292,7 +264,7 @@ public class ImportService {
e.setDescription(d.description());
e.setDiceFormula(d.diceFormula());
e.setIcon(d.icon());
- e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
+ e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setOrder(d.order());
List entries = new ArrayList<>();
for (ContentExport.RandomTableEntryDto en : nullSafe(d.entries())) {
@@ -318,7 +290,7 @@ public class ImportService {
ChapterJpaEntity e = new ChapterJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
- e.setArcId(remapRequired(arcMap, d.arcId()));
+ e.setArcId(IdRemapper.remapId(arcMap, d.arcId()));
e.setOrder(d.order());
e.setPrerequisites(PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson())); // remappe plus bas
e.setIcon(d.icon());
@@ -341,7 +313,7 @@ public class ImportService {
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
- e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
+ e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
e.setFolder(d.folder());
e.setOrder(d.order());
@@ -360,7 +332,7 @@ public class ImportService {
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
- e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
+ e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setOrder(d.order());
enemyMap.put(d.id(), enemyRepo.save(e).getId());
}
@@ -375,7 +347,7 @@ public class ImportService {
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
- e.setCampaignId(remapNullable(campaignMap, d.campaignId()));
+ e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setPlaythroughId(null); // Playthrough hors perimetre d'export
e.setOrder(d.order());
characterMap.put(d.id(), characterRepo.save(e).getId());
@@ -387,7 +359,7 @@ public class ImportService {
SceneJpaEntity e = new SceneJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
- e.setChapterId(remapRequired(chapterMap, d.chapterId()));
+ e.setChapterId(IdRemapper.remapId(chapterMap, d.chapterId()));
e.setOrder(d.order());
e.setIcon(d.icon());
e.setLocation(d.location());
@@ -434,8 +406,8 @@ public class ImportService {
// Campaign.loreId & gameSystemId (refs faibles String -> remap via maps Long).
for (Long newCampaignId : campaignMap.values()) {
campaignRepo.findById(newCampaignId).ifPresent(c -> {
- String newLore = remapStringId(loreMap, c.getLoreId());
- String newGs = remapStringId(gameSystemMap, c.getGameSystemId());
+ String newLore = IdRemapper.remapStringId(loreMap, c.getLoreId());
+ String newGs = IdRemapper.remapStringId(gameSystemMap, c.getGameSystemId());
c.setLoreId(newLore);
c.setGameSystemId(newGs);
campaignRepo.save(c);
@@ -445,7 +417,7 @@ public class ImportService {
// Page.relatedPageIds
for (Long newPageId : pageMap.values()) {
pageRepo.findById(newPageId).ifPresent(p -> {
- p.setRelatedPageIds(remapStringList(pageMap, p.getRelatedPageIds()));
+ p.setRelatedPageIds(IdRemapper.remapStringList(pageMap, p.getRelatedPageIds()));
pageRepo.save(p);
});
}
@@ -453,7 +425,7 @@ public class ImportService {
// Arc.relatedPageIds
for (Long newArcId : arcMap.values()) {
arcRepo.findById(newArcId).ifPresent(a -> {
- a.setRelatedPageIds(remapStringList(pageMap, a.getRelatedPageIds()));
+ a.setRelatedPageIds(IdRemapper.remapStringList(pageMap, a.getRelatedPageIds()));
arcRepo.save(a);
});
}
@@ -461,8 +433,8 @@ public class ImportService {
// Chapter.relatedPageIds + prerequisites(QuestCompleted -> map Chapter)
for (Long newChapterId : chapterMap.values()) {
chapterRepo.findById(newChapterId).ifPresent(c -> {
- c.setRelatedPageIds(remapStringList(pageMap, c.getRelatedPageIds()));
- c.setPrerequisites(remapPrerequisites(chapterMap, c.getPrerequisites()));
+ c.setRelatedPageIds(IdRemapper.remapStringList(pageMap, c.getRelatedPageIds()));
+ c.setPrerequisites(IdRemapper.remapPrerequisites(chapterMap, c.getPrerequisites()));
chapterRepo.save(c);
});
}
@@ -470,7 +442,7 @@ public class ImportService {
// Npc.relatedPageIds
for (Long newNpcId : npcMap.values()) {
npcRepo.findById(newNpcId).ifPresent(n -> {
- n.setRelatedPageIds(remapStringList(pageMap, n.getRelatedPageIds()));
+ n.setRelatedPageIds(IdRemapper.remapStringList(pageMap, n.getRelatedPageIds()));
npcRepo.save(n);
});
}
@@ -478,9 +450,9 @@ public class ImportService {
// Scene.relatedPageIds + enemyIds(map Enemy) + branches.targetSceneId(map Scene)
for (Long newSceneId : sceneMap.values()) {
sceneRepo.findById(newSceneId).ifPresent(s -> {
- s.setRelatedPageIds(remapStringList(pageMap, s.getRelatedPageIds()));
- s.setEnemyIds(remapStringList(enemyMap, s.getEnemyIds()));
- s.setBranches(remapBranches(sceneMap, s.getBranches()));
+ s.setRelatedPageIds(IdRemapper.remapStringList(pageMap, s.getRelatedPageIds()));
+ s.setEnemyIds(IdRemapper.remapStringList(enemyMap, s.getEnemyIds()));
+ s.setBranches(IdRemapper.remapBranches(sceneMap, s.getBranches()));
sceneRepo.save(s);
});
}
@@ -488,104 +460,40 @@ public class ImportService {
return result.build();
}
- // ----- Images -----
+ // ----- Lecture de l'archive -----
- private void importImages(ContentExport export,
- Map imageBinaries,
- ImportResult.Builder result) {
- // Index des metadonnees d'image par cle (depuis le data.json).
- Map metaByKey = new HashMap<>();
- for (ContentExport.ImageDto img : nullSafe(export.images())) {
- if (img.storageKey() != null) metaByKey.put(img.storageKey(), img);
- }
+ /** Contenu déballé d'un zip d'import : le {@code data.json} parsé + les binaires d'images. */
+ private record ParsedArchive(ContentExport export, Map imageBinaries) {
+ }
- for (Map.Entry bin : imageBinaries.entrySet()) {
- String storageKey = bin.getKey();
- byte[] data = bin.getValue();
- if (imageRepo.findByStorageKey(storageKey).isPresent()) {
- // Image deja presente : on reutilise, pas de reupload (eviter doublon).
- result.imageReused();
- continue;
+ /**
+ * Déballe le zip : {@code data.json → ContentExport} et {@code images/ → binaire}
+ * (le {@code manifest.json} est ignoré, info seulement). Lève si {@code data.json} manque.
+ */
+ private ParsedArchive parseArchive(InputStream zipStream) {
+ ContentExport export = null;
+ Map imageBinaries = new LinkedHashMap<>(); // storageKey -> binaire
+ try (ZipInputStream zip = new ZipInputStream(zipStream)) {
+ ZipEntry entry;
+ while ((entry = zip.getNextEntry()) != null) {
+ String name = entry.getName();
+ if ("data.json".equals(name)) {
+ export = objectMapper.readValue(readAll(zip), ContentExport.class);
+ } else if (name.startsWith("images/") && !entry.isDirectory()) {
+ // La cle de stockage est le chemin sans le prefixe "images/" du zip,
+ // c'est-a-dire EXACTEMENT le storageKey d'origine ("images/UUID.ext").
+ String storageKey = name.substring("images/".length());
+ imageBinaries.put(storageKey, readAll(zip));
+ }
+ zip.closeEntry();
}
- ContentExport.ImageDto meta = metaByKey.get(storageKey);
- String contentType = meta != null && meta.contentType() != null
- ? meta.contentType() : guessContentType(storageKey);
- long size = meta != null ? meta.sizeBytes() : data.length;
-
- imageStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length);
-
- ImageJpaEntity e = new ImageJpaEntity();
- e.setFilename(meta != null && meta.filename() != null
- ? meta.filename() : fileNameOf(storageKey));
- e.setContentType(contentType);
- e.setSizeBytes(size);
- e.setStorageKey(storageKey);
- imageRepo.save(e);
- result.imageUploaded();
+ } catch (IOException e) {
+ throw new UncheckedIOException("Echec de lecture du zip d'import", e);
}
- }
-
- // ----- Helpers de remapping -----
-
- /** Remap obligatoire d'une FK Long : si absente de la map, on garde l'ancienne valeur. */
- private Long remapRequired(Map map, Long oldId) {
- if (oldId == null) return null;
- return map.getOrDefault(oldId, oldId);
- }
-
- /** Remap d'une FK Long nullable : null reste null. */
- private Long remapNullable(Map map, Long oldId) {
- if (oldId == null) return null;
- return map.getOrDefault(oldId, oldId);
- }
-
- /** Remap d'un id stocke en String ("oldLong" -> "newLong") via une map Long. */
- private String remapStringId(Map map, String oldId) {
- if (oldId == null || oldId.isBlank()) return oldId;
- try {
- Long newId = map.get(Long.parseLong(oldId.trim()));
- return newId != null ? String.valueOf(newId) : oldId;
- } catch (NumberFormatException ex) {
- return oldId; // pas un Long : on laisse tel quel
- }
- }
-
- private List remapStringList(Map map, List ids) {
- if (ids == null) return null;
- List out = new ArrayList<>(ids.size());
- for (String id : ids) out.add(remapStringId(map, id));
- return out;
- }
-
- private List remapPrerequisites(Map chapterMap, List prereqs) {
- if (prereqs == null) return null;
- List out = new ArrayList<>(prereqs.size());
- for (Prerequisite p : prereqs) {
- if (p instanceof Prerequisite.QuestCompleted qc) {
- out.add(new Prerequisite.QuestCompleted(remapStringId(chapterMap, qc.questId())));
- } else {
- out.add(p); // FlagSet / SessionReached : inchanges
- }
- }
- return out;
- }
-
- private List remapBranches(Map sceneMap, List branches) {
- if (branches == null) return null;
- List out = new ArrayList<>(branches.size());
- for (SceneBranch b : branches) {
- out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition()));
- }
- return out;
- }
-
- private com.loremind.domain.campaigncontext.ArcType parseArcType(String type) {
- if (type == null) return com.loremind.domain.campaigncontext.ArcType.LINEAR;
- try {
- return com.loremind.domain.campaigncontext.ArcType.valueOf(type);
- } catch (IllegalArgumentException ex) {
- return com.loremind.domain.campaigncontext.ArcType.LINEAR;
+ if (export == null) {
+ throw new IllegalArgumentException("Archive invalide : data.json introuvable");
}
+ return new ParsedArchive(export, imageBinaries);
}
// ----- Helpers divers -----
@@ -599,18 +507,4 @@ public class ImportService {
in.transferTo(buffer);
return buffer.toByteArray();
}
-
- private static String fileNameOf(String storageKey) {
- int slash = storageKey.lastIndexOf('/');
- return slash >= 0 ? storageKey.substring(slash + 1) : storageKey;
- }
-
- private static String guessContentType(String storageKey) {
- String lower = storageKey.toLowerCase();
- if (lower.endsWith(".png")) return "image/png";
- if (lower.endsWith(".gif")) return "image/gif";
- if (lower.endsWith(".webp")) return "image/webp";
- if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
- return "application/octet-stream";
- }
}
diff --git a/core/src/main/java/com/loremind/infrastructure/updates/SemverComparator.java b/core/src/main/java/com/loremind/infrastructure/updates/SemverComparator.java
new file mode 100644
index 0000000..1c3baf8
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/updates/SemverComparator.java
@@ -0,0 +1,81 @@
+package com.loremind.infrastructure.updates;
+
+import org.springframework.lang.Nullable;
+
+import java.util.List;
+
+/**
+ * Parsing et comparaison de versions semver (stratégie de {@link UpdateCheckService}).
+ *
+ * Volontairement tolérant : préfixe {@code v}/{@code V} accepté, pré-release
+ * ({@code -beta.1}) et build metadata ({@code +build.42}) strippés avant comparaison.
+ * Un tag non parsable est simplement ignoré (jamais d'exception). Fonctions PURES.
+ */
+final class SemverComparator {
+
+ private SemverComparator() {
+ }
+
+ /**
+ * Parcourt la liste des tags, garde uniquement ceux qui parsent en semver
+ * (1 à 3 chiffres séparés par des points, préfixe {@code v} optionnel),
+ * retourne le plus élevé, ou {@code null} si aucun n'est valide.
+ */
+ @Nullable
+ static String findMaxSemver(List tags) {
+ String maxTag = null;
+ int[] maxParts = null;
+ for (String t : tags) {
+ if (t == null || t.isBlank()) continue;
+ int[] parts = parseSemver(t);
+ if (parts == null) continue;
+ if (maxParts == null || compareParts(parts, maxParts) > 0) {
+ maxParts = parts;
+ maxTag = t;
+ }
+ }
+ return maxTag;
+ }
+
+ /** @return {@code [major, minor, patch]} ou {@code null} si non parsable. */
+ @Nullable
+ static int[] parseSemver(String tag) {
+ if (tag == null) return null;
+ String s = tag.trim();
+ if (s.isEmpty()) return null;
+ if (s.startsWith("v") || s.startsWith("V")) s = s.substring(1);
+ int dashIdx = s.indexOf('-');
+ if (dashIdx > 0) s = s.substring(0, dashIdx);
+ int plusIdx = s.indexOf('+');
+ if (plusIdx > 0) s = s.substring(0, plusIdx);
+ String[] parts = s.split("\\.");
+ if (parts.length < 1 || parts.length > 3) return null;
+ int[] result = new int[]{0, 0, 0};
+ for (int i = 0; i < parts.length; i++) {
+ try {
+ int v = Integer.parseInt(parts[i]);
+ if (v < 0) return null;
+ result[i] = v;
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+ return result;
+ }
+
+ /** Compare deux versions semver brutes (préfixe toléré). Négatif si {@code a < b}. */
+ static int compareSemver(String a, String b) {
+ int[] aParts = parseSemver(a);
+ int[] bParts = parseSemver(b);
+ if (aParts == null || bParts == null) return 0;
+ return compareParts(aParts, bParts);
+ }
+
+ private static int compareParts(int[] a, int[] b) {
+ for (int i = 0; i < 3; i++) {
+ int diff = Integer.compare(a[i], b[i]);
+ if (diff != 0) return diff;
+ }
+ return 0;
+ }
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java b/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java
index 2c53f94..bb81920 100644
--- a/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java
+++ b/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java
@@ -25,7 +25,6 @@ import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
-import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -114,32 +113,9 @@ public class UpdateCheckService {
return new UpdateStatus(true, false, true, null, statuses, Instant.now());
}
- List statuses = new ArrayList<>();
- boolean anyUpdate = false;
- boolean anyUnknown = false;
- for (String image : images) {
- String latest = null;
- try {
- latest = fetchLatestSemverTag(registry, image, null);
- } catch (Exception e) {
- log.warn("Tags fetch failed for {}: {}", image, e.getMessage());
- }
- ImageStatusKind kind;
- if (latest == null) {
- kind = ImageStatusKind.UNKNOWN;
- anyUnknown = true;
- } else {
- int cmp = compareSemver(currentVersion, latest);
- if (cmp >= 0) {
- kind = ImageStatusKind.UP_TO_DATE;
- } else {
- kind = ImageStatusKind.UPDATE_AVAILABLE;
- anyUpdate = true;
- }
- }
- statuses.add(new ImageStatus(image, currentVersion, latest, kind));
- }
- return new UpdateStatus(true, anyUpdate, anyUnknown, currentVersion, statuses, Instant.now());
+ ImagesEvaluation ev = evaluateImages(images, registry, null);
+ return new UpdateStatus(true, ev.anyUpdate(), ev.anyUnknown(),
+ currentVersion, ev.statuses(), Instant.now());
}
/**
@@ -169,21 +145,37 @@ public class UpdateCheckService {
(creds.get().username() + ":" + creds.get().password()).getBytes(StandardCharsets.UTF_8));
String betaRegistry = normalizeRegistry(creds.get().registry());
+ ImagesEvaluation ev = evaluateImages(betaImages, betaRegistry, basicAuth);
+ return new BetaStatus(true, ev.anyUpdate(), ev.anyUnknown(),
+ ev.statuses(), Instant.now(), null);
+ }
+
+ /** Résultat de l'évaluation d'une liste d'images : statuts + drapeaux agrégés. */
+ private record ImagesEvaluation(List statuses, boolean anyUpdate, boolean anyUnknown) {
+ }
+
+ /**
+ * Boucle commune à {@link #check()} et {@link #checkBeta()} : pour chaque image,
+ * récupère le tag semver le plus haut et le compare à la version courante.
+ * Une image dont les tags sont injoignables ou non semver → {@code UNKNOWN}.
+ */
+ private ImagesEvaluation evaluateImages(List imageList, String registryUrl,
+ @Nullable String authHeader) {
List statuses = new ArrayList<>();
boolean anyUpdate = false;
boolean anyUnknown = false;
- for (String image : betaImages) {
+ for (String image : imageList) {
String latest = null;
try {
- latest = fetchLatestSemverTag(betaRegistry, image, basicAuth);
+ latest = fetchLatestSemverTag(registryUrl, image, authHeader);
} catch (Exception e) {
- log.warn("Beta tags fetch failed for {}: {}", image, e.getMessage());
+ log.warn("Tags fetch failed for {}: {}", image, e.getMessage());
}
ImageStatusKind kind;
if (latest == null) {
kind = ImageStatusKind.UNKNOWN;
anyUnknown = true;
- } else if (currentVersion != null && compareSemver(currentVersion, latest) >= 0) {
+ } else if (currentVersion != null && SemverComparator.compareSemver(currentVersion, latest) >= 0) {
kind = ImageStatusKind.UP_TO_DATE;
} else {
kind = ImageStatusKind.UPDATE_AVAILABLE;
@@ -191,7 +183,7 @@ public class UpdateCheckService {
}
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
}
- return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null);
+ return new ImagesEvaluation(statuses, anyUpdate, anyUnknown);
}
public void apply() {
@@ -209,6 +201,10 @@ public class UpdateCheckService {
// -----------------------------------------------------------------------
// Registry HTTP API v2 - tags listing + auth bearer
+ //
+ // NB : le RestTemplate (champ `http`) reste porté par ce service — les tests
+ // l'injectent par réflexion. Le parsing semver et le parsing du challenge
+ // WWW-Authenticate sont, eux, externalisés (SemverComparator, WwwAuthenticate).
// -----------------------------------------------------------------------
/**
@@ -244,7 +240,7 @@ public class UpdateCheckService {
body = tagsCall(url, bearerHeaders);
}
if (body == null || body.tags == null || body.tags.isEmpty()) return null;
- return findMaxSemver(body.tags);
+ return SemverComparator.findMaxSemver(body.tags);
}
private TagsListResponse tagsCall(String url, HttpHeaders headers) {
@@ -253,69 +249,6 @@ public class UpdateCheckService {
return resp.getBody();
}
- /**
- * Parcourt la liste des tags, garde uniquement ceux qui parsent en semver
- * (1 a 3 chiffres separes par des points, optionnel prefix "v"), retourne le max.
- * Pre-release / build metadata sont strippes pour la comparaison.
- */
- @Nullable
- static String findMaxSemver(List tags) {
- String maxTag = null;
- int[] maxParts = null;
- for (String t : tags) {
- if (t == null || t.isBlank()) continue;
- int[] parts = parseSemver(t);
- if (parts == null) continue;
- if (maxParts == null || compareParts(parts, maxParts) > 0) {
- maxParts = parts;
- maxTag = t;
- }
- }
- return maxTag;
- }
-
- /** @return [major, minor, patch] ou null si non parsable. */
- @Nullable
- static int[] parseSemver(String tag) {
- if (tag == null) return null;
- String s = tag.trim();
- if (s.isEmpty()) return null;
- if (s.startsWith("v") || s.startsWith("V")) s = s.substring(1);
- int dashIdx = s.indexOf('-');
- if (dashIdx > 0) s = s.substring(0, dashIdx);
- int plusIdx = s.indexOf('+');
- if (plusIdx > 0) s = s.substring(0, plusIdx);
- String[] parts = s.split("\\.");
- if (parts.length < 1 || parts.length > 3) return null;
- int[] result = new int[]{0, 0, 0};
- for (int i = 0; i < parts.length; i++) {
- try {
- int v = Integer.parseInt(parts[i]);
- if (v < 0) return null;
- result[i] = v;
- } catch (NumberFormatException e) {
- return null;
- }
- }
- return result;
- }
-
- /** Compare deux versions semver brutes (sans prefix). Negatif si a < b. */
- static int compareSemver(String a, String b) {
- int[] aParts = parseSemver(a);
- int[] bParts = parseSemver(b);
- if (aParts == null || bParts == null) return 0;
- return compareParts(aParts, bParts);
- }
-
- private static int compareParts(int[] a, int[] b) {
- for (int i = 0; i < 3; i++) {
- int diff = Integer.compare(a[i], b[i]);
- if (diff != 0) return diff;
- }
- return 0;
- }
-
/**
* Suit le challenge {@code WWW-Authenticate: Bearer realm="..."} pour obtenir
* un token. Si {@code basicAuth} est fourni, l'utilise pour l'echange (cas
@@ -326,7 +259,7 @@ public class UpdateCheckService {
if (wwwAuth == null) return null;
String prefix = "Bearer ";
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
- Map params = parseAuthParams(wwwAuth.substring(prefix.length()));
+ Map params = WwwAuthenticate.parseParams(wwwAuth.substring(prefix.length()));
String realm = params.get("realm");
if (realm == null) return null;
StringBuilder url = new StringBuilder(realm);
@@ -359,34 +292,6 @@ public class UpdateCheckService {
}
}
- /** Parser minimaliste pour {@code key="value", key2="value2"}. */
- private static Map parseAuthParams(String s) {
- Map out = new HashMap<>();
- int i = 0;
- int n = s.length();
- while (i < n) {
- while (i < n && (s.charAt(i) == ',' || s.charAt(i) == ' ')) i++;
- int eq = s.indexOf('=', i);
- if (eq < 0) break;
- String key = s.substring(i, eq).trim();
- int valStart = eq + 1;
- String val;
- if (valStart < n && s.charAt(valStart) == '"') {
- int valEnd = s.indexOf('"', valStart + 1);
- if (valEnd < 0) break;
- val = s.substring(valStart + 1, valEnd);
- i = valEnd + 1;
- } else {
- int valEnd = s.indexOf(',', valStart);
- if (valEnd < 0) valEnd = n;
- val = s.substring(valStart, valEnd).trim();
- i = valEnd;
- }
- out.put(key, val);
- }
- return out;
- }
-
private static String normalizeRegistry(String value) {
if (value == null || value.isBlank()) return "";
String v = value.trim();
diff --git a/core/src/main/java/com/loremind/infrastructure/updates/WwwAuthenticate.java b/core/src/main/java/com/loremind/infrastructure/updates/WwwAuthenticate.java
new file mode 100644
index 0000000..742a374
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/updates/WwwAuthenticate.java
@@ -0,0 +1,46 @@
+package com.loremind.infrastructure.updates;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Parseur minimaliste des paramètres d'un challenge HTTP
+ * {@code WWW-Authenticate: Bearer realm="...", service="...", scope="..."}
+ * (cf. le flux de token du registry Docker dans {@link UpdateCheckService}).
+ *
+ * Accepte les valeurs entre guillemets ({@code key="value"}) comme non quotées
+ * ({@code key=value}), séparées par des virgules et/ou espaces. Fonction PURE.
+ */
+final class WwwAuthenticate {
+
+ private WwwAuthenticate() {
+ }
+
+ /** Parse {@code key="value", key2=value2} en map clé→valeur (sans les guillemets). */
+ static Map parseParams(String s) {
+ Map out = new HashMap<>();
+ int i = 0;
+ int n = s.length();
+ while (i < n) {
+ while (i < n && (s.charAt(i) == ',' || s.charAt(i) == ' ')) i++;
+ int eq = s.indexOf('=', i);
+ if (eq < 0) break;
+ String key = s.substring(i, eq).trim();
+ int valStart = eq + 1;
+ String val;
+ if (valStart < n && s.charAt(valStart) == '"') {
+ int valEnd = s.indexOf('"', valStart + 1);
+ if (valEnd < 0) break;
+ val = s.substring(valStart + 1, valEnd);
+ i = valEnd + 1;
+ } else {
+ int valEnd = s.indexOf(',', valStart);
+ if (valEnd < 0) valEnd = n;
+ val = s.substring(valStart, valEnd).trim();
+ i = valEnd;
+ }
+ out.put(key, val);
+ }
+ return out;
+ }
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/AiChatController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/AiChatController.java
index 6247c1f..a73ef25 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/controller/AiChatController.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/controller/AiChatController.java
@@ -9,6 +9,7 @@ import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamCampaignRequestDTO;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamSessionRequestDTO;
+import com.loremind.infrastructure.web.sse.SseJson;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType;
@@ -162,7 +163,7 @@ public class AiChatController {
private void sendToken(SseEmitter emitter, String token) {
try {
emitter.send(SseEmitter.event()
- .data("{\"token\":" + jsonEscape(token) + "}"));
+ .data("{\"token\":" + SseJson.escape(token) + "}"));
} catch (IOException e) {
emitter.completeWithError(e);
}
@@ -182,7 +183,7 @@ public class AiChatController {
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
emitter.send(SseEmitter.event()
.name("error")
- .data("{\"message\":" + jsonEscape(message) + "}"));
+ .data("{\"message\":" + SseJson.escape(message) + "}"));
emitter.complete();
} catch (IOException ioe) {
emitter.completeWithError(ioe);
@@ -191,31 +192,6 @@ public class AiChatController {
// --- Utilitaires --------------------------------------------------------
- /** Encadre une chaîne de guillemets et échappe les caractères JSON dangereux. */
- private String jsonEscape(String raw) {
- if (raw == null) return "\"\"";
- StringBuilder sb = new StringBuilder(raw.length() + 2);
- sb.append('"');
- for (int i = 0; i < raw.length(); i++) {
- char c = raw.charAt(i);
- switch (c) {
- case '"': sb.append("\\\""); break;
- case '\\': sb.append("\\\\"); break;
- case '\n': sb.append("\\n"); break;
- case '\r': sb.append("\\r"); break;
- case '\t': sb.append("\\t"); break;
- default:
- if (c < 0x20) {
- sb.append(String.format("\\u%04x", (int) c));
- } else {
- sb.append(c);
- }
- }
- }
- sb.append('"');
- return sb.toString();
- }
-
private List toDomainMessages(List dtos) {
if (dtos == null) return List.of();
return dtos.stream()
diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java
index c30d5fd..855468d 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java
@@ -5,10 +5,8 @@ import com.loremind.application.gamesystemcontext.GameSystemService;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
-import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.gamesystemcontext.RulesImportResponseDTO;
-import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
import org.slf4j.Logger;
@@ -23,7 +21,6 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
-import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -261,18 +258,11 @@ public class GameSystemController {
dto.getName(),
dto.getDescription(),
dto.getRulesMarkdown(),
- toDomainFields(dto.getCharacterTemplate()),
- toDomainFields(dto.getNpcTemplate()),
- toDomainFields(dto.getEnemyTemplate()),
+ templateFieldMapper.toDomainList(dto.getCharacterTemplate()),
+ templateFieldMapper.toDomainList(dto.getNpcTemplate()),
+ templateFieldMapper.toDomainList(dto.getEnemyTemplate()),
dto.getAuthor(),
dto.isPublic()
);
}
-
- private List toDomainFields(List dtos) {
- if (dtos == null) return new ArrayList<>();
- List out = new ArrayList<>(dtos.size());
- for (TemplateFieldDTO d : dtos) out.add(templateFieldMapper.toDomain(d));
- return out;
- }
}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java
index ea56a63..a71aa98 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java
@@ -5,6 +5,7 @@ import com.loremind.domain.campaigncontext.Notebook;
import com.loremind.domain.campaigncontext.NotebookSource;
import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer;
import com.loremind.domain.campaigncontext.ports.NotebookException;
+import com.loremind.infrastructure.web.sse.SseJson;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.HttpStatus;
@@ -211,7 +212,7 @@ public class NotebookController {
private void sendToken(SseEmitter emitter, String token) {
try {
- emitter.send(SseEmitter.event().name("token").data("{\"token\":" + jsonEscape(token) + "}"));
+ emitter.send(SseEmitter.event().name("token").data("{\"token\":" + SseJson.escape(token) + "}"));
} catch (IOException | IllegalStateException e) {
// flux fermé/expiré : on cesse d'écrire
}
@@ -247,32 +248,13 @@ public class NotebookController {
private void fail(SseEmitter emitter, Throwable error) {
try {
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
- emitter.send(SseEmitter.event().name("error").data("{\"message\":" + jsonEscape(message) + "}"));
+ emitter.send(SseEmitter.event().name("error").data("{\"message\":" + SseJson.escape(message) + "}"));
emitter.complete();
} catch (IOException | IllegalStateException e) {
// flux déjà fermé/expiré : rien à envoyer
}
}
- private String jsonEscape(String raw) {
- if (raw == null) return "\"\"";
- StringBuilder sb = new StringBuilder(raw.length() + 2).append('"');
- for (int i = 0; i < raw.length(); i++) {
- char c = raw.charAt(i);
- switch (c) {
- case '"': sb.append("\\\""); break;
- case '\\': sb.append("\\\\"); break;
- case '\n': sb.append("\\n"); break;
- case '\r': sb.append("\\r"); break;
- case '\t': sb.append("\\t"); break;
- default:
- if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
- else sb.append(c);
- }
- }
- return sb.append('"').toString();
- }
-
public record CreateRequest(String campaignId, String name) {}
public record RenameRequest(String name) {}
/**
diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java
index eb19bc8..c611dc0 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/GameSystemMapper.java
@@ -1,14 +1,9 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.gamesystemcontext.GameSystem;
-import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
-import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import org.springframework.stereotype.Component;
-import java.util.ArrayList;
-import java.util.List;
-
@Component
public class GameSystemMapper {
@@ -25,9 +20,9 @@ public class GameSystemMapper {
dto.setName(g.getName());
dto.setDescription(g.getDescription());
dto.setRulesMarkdown(g.getRulesMarkdown());
- dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate()));
- dto.setNpcTemplate(toDTOList(g.getNpcTemplate()));
- dto.setEnemyTemplate(toDTOList(g.getEnemyTemplate()));
+ dto.setCharacterTemplate(fieldMapper.toDTOList(g.getCharacterTemplate()));
+ dto.setNpcTemplate(fieldMapper.toDTOList(g.getNpcTemplate()));
+ dto.setEnemyTemplate(fieldMapper.toDTOList(g.getEnemyTemplate()));
dto.setAuthor(g.getAuthor());
dto.setPublic(g.isPublic());
return dto;
@@ -40,25 +35,11 @@ public class GameSystemMapper {
.name(dto.getName())
.description(dto.getDescription())
.rulesMarkdown(dto.getRulesMarkdown())
- .characterTemplate(toDomainList(dto.getCharacterTemplate()))
- .npcTemplate(toDomainList(dto.getNpcTemplate()))
- .enemyTemplate(toDomainList(dto.getEnemyTemplate()))
+ .characterTemplate(fieldMapper.toDomainList(dto.getCharacterTemplate()))
+ .npcTemplate(fieldMapper.toDomainList(dto.getNpcTemplate()))
+ .enemyTemplate(fieldMapper.toDomainList(dto.getEnemyTemplate()))
.author(dto.getAuthor())
.isPublic(dto.isPublic())
.build();
}
-
- private List toDTOList(List fields) {
- if (fields == null) return new ArrayList<>();
- List out = new ArrayList<>(fields.size());
- for (TemplateField f : fields) out.add(fieldMapper.toDTO(f));
- return out;
- }
-
- private List toDomainList(List dtos) {
- if (dtos == null) return new ArrayList<>();
- List out = new ArrayList<>(dtos.size());
- for (TemplateFieldDTO d : dtos) out.add(fieldMapper.toDomain(d));
- return out;
- }
}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java
index 0807726..0fe11e2 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java
@@ -60,4 +60,20 @@ public class TemplateFieldMapper {
}
return new TemplateField(dto.getName(), type, layout, labels);
}
+
+ /** Mappe une liste de champs domaine → DTO ({@code null} → liste vide). */
+ public List toDTOList(List fields) {
+ if (fields == null) return new ArrayList<>();
+ List out = new ArrayList<>(fields.size());
+ for (TemplateField f : fields) out.add(toDTO(f));
+ return out;
+ }
+
+ /** Mappe une liste de champs DTO → domaine ({@code null} → liste vide). */
+ public List toDomainList(List dtos) {
+ if (dtos == null) return new ArrayList<>();
+ List out = new ArrayList<>(dtos.size());
+ for (TemplateFieldDTO d : dtos) out.add(toDomain(d));
+ return out;
+ }
}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/sse/SseJson.java b/core/src/main/java/com/loremind/infrastructure/web/sse/SseJson.java
new file mode 100644
index 0000000..7dbbfcc
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/web/sse/SseJson.java
@@ -0,0 +1,45 @@
+package com.loremind.infrastructure.web.sse;
+
+/**
+ * Petits utilitaires d'écriture JSON pour les payloads SSE bâtis à la main par
+ * les controllers de streaming (chat IA, notebook…).
+ *
+ * Volontairement minimaliste (pas de Jackson) : les payloads concernés sont des
+ * objets plats à un ou deux champs ({@code {"token":"…"}}, {@code {"message":"…"}})
+ * écrits dans la boucle de streaming, où l'on veut éviter l'allocation d'un
+ * ObjectMapper par token.
+ */
+public final class SseJson {
+
+ private SseJson() {
+ }
+
+ /**
+ * Encadre {@code raw} de guillemets et échappe les caractères JSON dangereux
+ * (guillemet, antislash, retours chariot, tabulation, caractères de contrôle).
+ * Renvoie {@code "\"\""} (chaîne JSON vide) si {@code raw} est {@code null}.
+ */
+ public static String escape(String raw) {
+ if (raw == null) return "\"\"";
+ StringBuilder sb = new StringBuilder(raw.length() + 2);
+ sb.append('"');
+ for (int i = 0; i < raw.length(); i++) {
+ char c = raw.charAt(i);
+ switch (c) {
+ case '"': sb.append("\\\""); break;
+ case '\\': sb.append("\\\\"); break;
+ case '\n': sb.append("\\n"); break;
+ case '\r': sb.append("\\r"); break;
+ case '\t': sb.append("\\t"); break;
+ default:
+ if (c < 0x20) {
+ sb.append(String.format("\\u%04x", (int) c));
+ } else {
+ sb.append(c);
+ }
+ }
+ }
+ sb.append('"');
+ return sb.toString();
+ }
+}
diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/IdRemapperTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/IdRemapperTest.java
new file mode 100644
index 0000000..3a3aced
--- /dev/null
+++ b/core/src/test/java/com/loremind/infrastructure/transfer/IdRemapperTest.java
@@ -0,0 +1,143 @@
+package com.loremind.infrastructure.transfer;
+
+import com.loremind.domain.campaigncontext.ArcType;
+import com.loremind.domain.campaigncontext.Prerequisite;
+import com.loremind.domain.campaigncontext.SceneBranch;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+/**
+ * Tests unitaires PURS (sans Spring/DB) de {@link IdRemapper}, la logique de
+ * remapping {@code oldId → newId} de l'import en mode FUSION. Verrouille les
+ * invariants critiques : une référence absente de la map est CONSERVÉE, jamais
+ * perdue ni remplacée par null.
+ */
+class IdRemapperTest {
+
+ private static final Map MAP = Map.of(1L, 100L, 2L, 200L);
+
+ // --- remapId (FK Long) ---------------------------------------------------
+
+ @Test
+ void remapId_null_resteNull() {
+ assertNull(IdRemapper.remapId(MAP, null));
+ }
+
+ @Test
+ void remapId_present_estRemappe() {
+ assertEquals(100L, IdRemapper.remapId(MAP, 1L));
+ }
+
+ @Test
+ void remapId_absent_conserveLAncienneValeur() {
+ assertEquals(42L, IdRemapper.remapId(MAP, 42L));
+ }
+
+ // --- remapStringId (ref faible String) -----------------------------------
+
+ @Test
+ void remapStringId_null_resteNull() {
+ assertNull(IdRemapper.remapStringId(MAP, null));
+ }
+
+ @Test
+ void remapStringId_blanc_resteInchange() {
+ assertEquals(" ", IdRemapper.remapStringId(MAP, " "));
+ }
+
+ @Test
+ void remapStringId_numeriquePresent_estRemappe() {
+ assertEquals("200", IdRemapper.remapStringId(MAP, "2"));
+ }
+
+ @Test
+ void remapStringId_numeriqueAvecEspaces_estTrimmeEtRemappe() {
+ assertEquals("100", IdRemapper.remapStringId(MAP, " 1 "));
+ }
+
+ @Test
+ void remapStringId_numeriqueAbsent_conserveLAncien() {
+ assertEquals("999", IdRemapper.remapStringId(MAP, "999"));
+ }
+
+ @Test
+ void remapStringId_nonNumerique_resteInchange() {
+ assertEquals("abc-uuid", IdRemapper.remapStringId(MAP, "abc-uuid"));
+ }
+
+ // --- remapStringList -----------------------------------------------------
+
+ @Test
+ void remapStringList_null_resteNull() {
+ assertNull(IdRemapper.remapStringList(MAP, null));
+ }
+
+ @Test
+ void remapStringList_remappeChaqueElement() {
+ assertEquals(List.of("100", "999", "200"),
+ IdRemapper.remapStringList(MAP, List.of("1", "999", "2")));
+ }
+
+ // --- remapPrerequisites --------------------------------------------------
+
+ @Test
+ void remapPrerequisites_null_resteNull() {
+ assertNull(IdRemapper.remapPrerequisites(MAP, null));
+ }
+
+ @Test
+ void remapPrerequisites_questCompleted_remappeLeQuestId() {
+ List out = IdRemapper.remapPrerequisites(
+ MAP, List.of(new Prerequisite.QuestCompleted("1")));
+ assertEquals("100", ((Prerequisite.QuestCompleted) out.get(0)).questId());
+ }
+
+ @Test
+ void remapPrerequisites_autresTypes_inchanges() {
+ Prerequisite flag = new Prerequisite.FlagSet("porte_ouverte");
+ Prerequisite session = new Prerequisite.SessionReached(3);
+ List out = IdRemapper.remapPrerequisites(MAP, List.of(flag, session));
+ // FlagSet / SessionReached ne sont pas réécrits : même instance.
+ assertSame(flag, out.get(0));
+ assertSame(session, out.get(1));
+ }
+
+ // --- remapBranches -------------------------------------------------------
+
+ @Test
+ void remapBranches_null_resteNull() {
+ assertNull(IdRemapper.remapBranches(MAP, null));
+ }
+
+ @Test
+ void remapBranches_remappeTargetSceneId_etPreserveLabelEtCondition() {
+ SceneBranch in = new SceneBranch("Si attaque", "2", "notes MJ");
+ SceneBranch out = IdRemapper.remapBranches(MAP, List.of(in)).get(0);
+ assertEquals("Si attaque", out.label());
+ assertEquals("200", out.targetSceneId());
+ assertEquals("notes MJ", out.condition());
+ }
+
+ // --- parseArcType --------------------------------------------------------
+
+ @Test
+ void parseArcType_null_donneLinear() {
+ assertEquals(ArcType.LINEAR, IdRemapper.parseArcType(null));
+ }
+
+ @Test
+ void parseArcType_valeurValide_estParsee() {
+ assertEquals(ArcType.HUB, IdRemapper.parseArcType("HUB"));
+ }
+
+ @Test
+ void parseArcType_valeurInconnue_retombeSurLinear() {
+ assertEquals(ArcType.LINEAR, IdRemapper.parseArcType("INEXISTANT"));
+ }
+}
diff --git a/core/src/test/java/com/loremind/infrastructure/updates/SemverComparatorTest.java b/core/src/test/java/com/loremind/infrastructure/updates/SemverComparatorTest.java
new file mode 100644
index 0000000..202123e
--- /dev/null
+++ b/core/src/test/java/com/loremind/infrastructure/updates/SemverComparatorTest.java
@@ -0,0 +1,62 @@
+package com.loremind.infrastructure.updates;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests des utilitaires semver (extraits de UpdateCheckServiceTest lors de la
+ * séparation de {@link SemverComparator}).
+ */
+class SemverComparatorTest {
+
+ @Test
+ void parseSemver_acceptsCommonFormats() {
+ assertArrayEquals(new int[]{0, 8, 0}, SemverComparator.parseSemver("0.8.0"));
+ assertArrayEquals(new int[]{0, 8, 0}, SemverComparator.parseSemver("v0.8.0"));
+ assertArrayEquals(new int[]{1, 0, 0}, SemverComparator.parseSemver("1.0.0"));
+ assertArrayEquals(new int[]{0, 8, 0}, SemverComparator.parseSemver("0.8.0-beta.1"));
+ assertArrayEquals(new int[]{0, 8, 0}, SemverComparator.parseSemver("0.8.0+build.42"));
+ }
+
+ @Test
+ void parseSemver_rejectsInvalid() {
+ assertNull(SemverComparator.parseSemver(null));
+ assertNull(SemverComparator.parseSemver(""));
+ assertNull(SemverComparator.parseSemver("latest"));
+ assertNull(SemverComparator.parseSemver("stable"));
+ assertNull(SemverComparator.parseSemver("0.8.0.1.2"));
+ assertNull(SemverComparator.parseSemver("0.x.0"));
+ }
+
+ @Test
+ void compareSemver_basic() {
+ assertTrue(SemverComparator.compareSemver("0.7.2", "0.8.0") < 0);
+ assertTrue(SemverComparator.compareSemver("0.8.0", "0.7.2") > 0);
+ assertEquals(0, SemverComparator.compareSemver("0.8.0", "0.8.0"));
+ assertEquals(0, SemverComparator.compareSemver("v0.8.0", "0.8.0"));
+ assertTrue(SemverComparator.compareSemver("0.8.0", "0.10.0") < 0);
+ assertTrue(SemverComparator.compareSemver("1.0.0", "0.99.99") > 0);
+ }
+
+ @Test
+ void findMaxSemver_picksHighest() {
+ assertEquals("0.8.0", SemverComparator.findMaxSemver(
+ List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest")));
+ assertEquals("0.10.0", SemverComparator.findMaxSemver(
+ List.of("0.8.0", "0.10.0", "0.9.5")));
+ assertEquals("v1.0.0", SemverComparator.findMaxSemver(
+ List.of("v0.8.0", "v1.0.0", "latest")));
+ }
+
+ @Test
+ void findMaxSemver_returnsNullWhenNoValidTag() {
+ assertNull(SemverComparator.findMaxSemver(List.of("latest", "stable", "main")));
+ assertNull(SemverComparator.findMaxSemver(List.of()));
+ }
+}
diff --git a/core/src/test/java/com/loremind/infrastructure/updates/UpdateCheckServiceTest.java b/core/src/test/java/com/loremind/infrastructure/updates/UpdateCheckServiceTest.java
index f1f94f2..42acf55 100644
--- a/core/src/test/java/com/loremind/infrastructure/updates/UpdateCheckServiceTest.java
+++ b/core/src/test/java/com/loremind/infrastructure/updates/UpdateCheckServiceTest.java
@@ -1,6 +1,5 @@
package com.loremind.infrastructure.updates;
-import com.loremind.application.licensing.LicenseService;
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus;
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind;
import com.loremind.infrastructure.updates.UpdateCheckService.TagsListResponse;
@@ -194,52 +193,6 @@ public class UpdateCheckServiceTest {
}
}
- // -----------------------------------------------------------------
- // Utilitaires semver
- // -----------------------------------------------------------------
-
- @Test
- void parseSemver_acceptsCommonFormats() {
- assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0"));
- assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("v0.8.0"));
- assertArrayEquals(new int[]{1, 0, 0}, UpdateCheckService.parseSemver("1.0.0"));
- assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0-beta.1"));
- assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0+build.42"));
- }
-
- @Test
- void parseSemver_rejectsInvalid() {
- assertNull(UpdateCheckService.parseSemver(null));
- assertNull(UpdateCheckService.parseSemver(""));
- assertNull(UpdateCheckService.parseSemver("latest"));
- assertNull(UpdateCheckService.parseSemver("stable"));
- assertNull(UpdateCheckService.parseSemver("0.8.0.1.2"));
- assertNull(UpdateCheckService.parseSemver("0.x.0"));
- }
-
- @Test
- void compareSemver_basic() {
- assertTrue(UpdateCheckService.compareSemver("0.7.2", "0.8.0") < 0);
- assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.7.2") > 0);
- assertEquals(0, UpdateCheckService.compareSemver("0.8.0", "0.8.0"));
- assertEquals(0, UpdateCheckService.compareSemver("v0.8.0", "0.8.0"));
- assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.10.0") < 0);
- assertTrue(UpdateCheckService.compareSemver("1.0.0", "0.99.99") > 0);
- }
-
- @Test
- void findMaxSemver_picksHighest() {
- assertEquals("0.8.0", UpdateCheckService.findMaxSemver(
- List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest")));
- assertEquals("0.10.0", UpdateCheckService.findMaxSemver(
- List.of("0.8.0", "0.10.0", "0.9.5")));
- assertEquals("v1.0.0", UpdateCheckService.findMaxSemver(
- List.of("v0.8.0", "v1.0.0", "latest")));
- }
-
- @Test
- void findMaxSemver_returnsNullWhenNoValidTag() {
- assertNull(UpdateCheckService.findMaxSemver(List.of("latest", "stable", "main")));
- assertNull(UpdateCheckService.findMaxSemver(List.of()));
- }
+ // NB : les utilitaires semver (parseSemver / findMaxSemver / compareSemver) sont
+ // désormais dans SemverComparator et testés par SemverComparatorTest.
}
diff --git a/core/src/test/java/com/loremind/infrastructure/updates/WwwAuthenticateTest.java b/core/src/test/java/com/loremind/infrastructure/updates/WwwAuthenticateTest.java
new file mode 100644
index 0000000..67f1c02
--- /dev/null
+++ b/core/src/test/java/com/loremind/infrastructure/updates/WwwAuthenticateTest.java
@@ -0,0 +1,45 @@
+package com.loremind.infrastructure.updates;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests du parseur de challenge {@code WWW-Authenticate} ({@link WwwAuthenticate}),
+ * jusque-là non couvert quand il était privé dans UpdateCheckService.
+ */
+class WwwAuthenticateTest {
+
+ @Test
+ void parse_challengeGhcrComplet() {
+ // Forme typique renvoyée par ghcr.io / Docker Hub.
+ Map p = WwwAuthenticate.parseParams(
+ "realm=\"https://ghcr.io/token\",service=\"ghcr.io\",scope=\"repository:org/img:pull\"");
+ assertEquals("https://ghcr.io/token", p.get("realm"));
+ assertEquals("ghcr.io", p.get("service"));
+ assertEquals("repository:org/img:pull", p.get("scope"));
+ }
+
+ @Test
+ void parse_valeursNonQuotees() {
+ Map p = WwwAuthenticate.parseParams("realm=https://r/token, service=reg");
+ assertEquals("https://r/token", p.get("realm"));
+ assertEquals("reg", p.get("service"));
+ }
+
+ @Test
+ void parse_chaineVide_donneMapVide() {
+ assertTrue(WwwAuthenticate.parseParams("").isEmpty());
+ }
+
+ @Test
+ void parse_guillemetNonFerme_sArreteProprement() {
+ // Valeur ouverte sans guillemet fermant : on s'arrête sans planter.
+ Map p = WwwAuthenticate.parseParams("realm=\"https://r/token");
+ assertNull(p.get("realm"));
+ }
+}
diff --git a/web/package-lock.json b/web/package-lock.json
index a250aaf..e08a3eb 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "loremind-web",
- "version": "0.16.1",
+ "version": "0.16.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loremind-web",
- "version": "0.16.1",
+ "version": "0.16.2",
"dependencies": {
"@angular/animations": "^21.2.16",
"@angular/common": "^21.2.16",
@@ -32,7 +32,9 @@
"@emnapi/core": "^1.11.0",
"@emnapi/runtime": "^1.11.0",
"@playwright/test": "^1.59.1",
- "typescript": "~5.9.3"
+ "@vitest/coverage-v8": "^4.1.9",
+ "typescript": "~5.9.3",
+ "vitest": "^4.1.9"
}
},
"node_modules/@algolia/abtesting": {
@@ -2518,6 +2520,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@discoveryjs/json-ext": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz",
@@ -5988,6 +6000,17 @@
"@types/node": "*"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -6009,6 +6032,13 @@
"@types/node": "*"
}
},
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -6101,7 +6131,6 @@
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
@@ -6210,6 +6239,158 @@
"vite": "^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
+ "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.9",
+ "ast-v8-to-istanbul": "^1.0.0",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.1.9",
+ "vitest": "4.1.9"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
+ "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.9",
+ "@vitest/utils": "4.1.9",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
+ "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.9",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
+ "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
+ "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.9",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
+ "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.9",
+ "@vitest/utils": "4.1.9",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
+ "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
+ "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.9",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils/node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@webassemblyjs/ast": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -6678,6 +6859,35 @@
"node": ">=12.0.0"
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz",
+ "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/autoprefixer": {
"version": "10.4.27",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
@@ -7099,6 +7309,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@@ -8136,6 +8356,16 @@
"node": ">=4.0"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -8196,6 +8426,16 @@
"node": ">=18.0.0"
}
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/exponential-backoff": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
@@ -8747,6 +8987,13 @@
"safe-buffer": "~5.1.0"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
@@ -9287,6 +9534,64 @@
"node": ">=10"
}
},
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-report/node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/istanbul-lib-report/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/jest-worker": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
@@ -9308,7 +9613,6 @@
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -9804,6 +10108,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
+ "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@babel/types": "^7.29.0",
+ "source-map-js": "^1.2.1"
+ }
+ },
"node_modules/make-dir": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
@@ -10612,6 +10928,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/obug": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
+ "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -11009,6 +11339,13 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -11798,7 +12135,6 @@
"integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -12281,6 +12617,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -12527,6 +12870,13 @@
"node": "^20.17.0 || >=22.9.0"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -12537,6 +12887,13 @@
"node": ">= 0.8"
}
},
+ "node_modules/std-env": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/stdin-discarder": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz",
@@ -12669,7 +13026,6 @@
"integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
"dev": true,
"license": "BSD-2-Clause",
- "peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
@@ -12768,6 +13124,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -12785,6 +13158,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -13173,6 +13556,97 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/vitest": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
+ "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vitest/expect": "4.1.9",
+ "@vitest/mocker": "4.1.9",
+ "@vitest/pretty-format": "4.1.9",
+ "@vitest/runner": "4.1.9",
+ "@vitest/snapshot": "4.1.9",
+ "@vitest/spy": "4.1.9",
+ "@vitest/utils": "4.1.9",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.9",
+ "@vitest/browser-preview": "4.1.9",
+ "@vitest/browser-webdriverio": "4.1.9",
+ "@vitest/coverage-istanbul": "4.1.9",
+ "@vitest/coverage-v8": "4.1.9",
+ "@vitest/ui": "4.1.9",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
"node_modules/watchpack": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
@@ -13892,6 +14366,23 @@
"node": ">= 8"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wildcard": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
diff --git a/web/package.json b/web/package.json
index 4a548dd..36aaa41 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,6 +1,6 @@
{
"name": "loremind-web",
- "version": "0.16.1",
+ "version": "0.16.2",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",
@@ -8,6 +8,9 @@
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
+ "test:unit": "vitest run",
+ "test:unit:watch": "vitest",
+ "test:unit:coverage": "vitest run --coverage",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui",
"e2e:headed": "playwright test --headed",
@@ -39,6 +42,8 @@
"@emnapi/core": "^1.11.0",
"@emnapi/runtime": "^1.11.0",
"@playwright/test": "^1.59.1",
- "typescript": "~5.9.3"
+ "@vitest/coverage-v8": "^4.1.9",
+ "typescript": "~5.9.3",
+ "vitest": "^4.1.9"
}
}
diff --git a/web/src/app/campaigns/campaign-tree.helper.spec.ts b/web/src/app/campaigns/campaign-tree.helper.spec.ts
new file mode 100644
index 0000000..9b47fd2
--- /dev/null
+++ b/web/src/app/campaigns/campaign-tree.helper.spec.ts
@@ -0,0 +1,81 @@
+import { describe, it, expect } from 'vitest';
+import { buildCampaignTree, CampaignTreeData } from './campaign-tree.helper';
+import { TranslateService } from '@ngx-translate/core';
+
+// TranslateService factice : renvoie la clé telle quelle (suffisant pour vérifier
+// la structure de l'arbre sans dépendre des traductions).
+const t = { instant: (k: string) => k } as unknown as TranslateService;
+
+function data(partial: Partial = {}): CampaignTreeData {
+ return {
+ arcs: [], chaptersByArc: {}, scenesByChapter: {},
+ characters: [], npcs: [], randomTables: [], enemies: [],
+ ...partial,
+ };
+}
+
+describe('buildCampaignTree', () => {
+ it('produit les nœuds fixes (PNJ, ennemis, tables, ateliers, catalogues, import) quand vide', () => {
+ const tree = buildCampaignTree('c1', data(), t);
+ expect(tree.map((n) => n.id)).toEqual([
+ 'npcs-root', 'enemies-root', 'random-tables-root',
+ 'notebooks-root', 'item-catalogs-root', 'import-pdf-root',
+ ]);
+ });
+
+ it('trie les arcs en ordre NUMÉRIQUE naturel (1, 2, 10)', () => {
+ const arcs = [
+ { id: 'a', name: '10. Final' },
+ { id: 'b', name: '2. Voyage' },
+ { id: 'c', name: '1. Intro' },
+ ];
+ const tree = buildCampaignTree('c1', data({ arcs: arcs as never }), t);
+ const arcLabels = tree.filter((n) => n.id?.startsWith('arc-')).map((n) => n.label);
+ expect(arcLabels).toEqual(['1. Intro', '2. Voyage', '10. Final']);
+ });
+
+ it('imbrique chapitres et scènes et marque les chapitres à prérequis', () => {
+ const tree = buildCampaignTree('camp', data({
+ arcs: [{ id: 'a1', name: 'Arc', type: 'LINEAR' }] as never,
+ chaptersByArc: { a1: [{ id: 'c1', name: 'Chap', prerequisites: [{}] }] } as never,
+ scenesByChapter: { c1: [{ id: 's1', name: 'Scene' }] } as never,
+ }), t);
+ const arc = tree.find((n) => n.id === 'arc-a1')!;
+ const chapter = arc.children![0];
+ expect(chapter.id).toBe('chapter-c1');
+ expect(chapter.meta).toBe('🔒'); // cadenas si prérequis
+ expect(chapter.children![0].id).toBe('scene-s1');
+ });
+
+ it('regroupe les PNJ par dossier et laisse les non classés à la racine', () => {
+ const npcs = [
+ { id: 'n1', name: 'Alice', folder: 'Ville' },
+ { id: 'n2', name: 'Bob', folder: 'Ville' },
+ { id: 'n3', name: 'Carl' },
+ ];
+ const tree = buildCampaignTree('c', data({ npcs: npcs as never }), t);
+ const npcsRoot = tree.find((n) => n.id === 'npcs-root')!;
+ expect(npcsRoot.meta).toBe('3');
+ const folder = npcsRoot.children!.find((c) => c.id === 'npc-folder-Ville')!;
+ expect(folder.children!.map((c) => c.label)).toEqual(['Alice', 'Bob']);
+ expect(npcsRoot.children!.some((c) => c.id === 'npc-n3')).toBe(true);
+ });
+
+ it("affiche le niveau de l'ennemi en méta", () => {
+ const tree = buildCampaignTree('c', data({
+ enemies: [{ id: 'e1', name: 'Gobelin', level: 3 }] as never,
+ }), t);
+ const enemiesRoot = tree.find((n) => n.id === 'enemies-root')!;
+ expect(enemiesRoot.children![0].meta).toBe('Niv. 3');
+ });
+
+ it('libelle « nouvelle quête » dans un arc HUB (vs « nouveau chapitre »)', () => {
+ const hub = buildCampaignTree('c', data({ arcs: [{ id: 'a', name: 'Hub', type: 'HUB' }] as never }), t)
+ .find((n) => n.id === 'arc-a')!;
+ expect(hub.createActions![0].label).toBe('campaignTree.newQuest');
+
+ const linear = buildCampaignTree('c', data({ arcs: [{ id: 'a', name: 'Lin', type: 'LINEAR' }] as never }), t)
+ .find((n) => n.id === 'arc-a')!;
+ expect(linear.createActions![0].label).toBe('campaignTree.newChapter');
+ });
+});
diff --git a/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts b/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts
index dc48374..8fe3bd7 100644
--- a/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts
+++ b/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts
@@ -329,7 +329,16 @@ export class GameSystemEditComponent implements OnInit {
const valid = this.sections.filter(s => s.title.trim() || s.content.trim());
if (valid.length === 0) return null;
return valid
- .map(s => `## ${s.title.trim() || 'Sans titre'}\n${s.content.trim()}`)
+ .map(s => {
+ // Le "## " est RESERVE aux titres de section (parseMarkdown decoupe dessus,
+ // tout comme le parser Java cote backend). Or le contenu genere par l'IA
+ // contient souvent des sous-titres "## " : sans rien faire, ils seraient
+ // re-interpretes comme de nouvelles sections au rechargement -> explosion
+ // de sections + sections vides. On les retrograde donc en "### " (un niveau
+ // plus bas) pour garder un round-trip stable. (### / #### ne collisionnent pas.)
+ const content = s.content.trim().replace(/^##[ \t]+/gm, '### ');
+ return `## ${s.title.trim() || 'Sans titre'}\n${content}`;
+ })
.join('\n\n');
}
}
diff --git a/web/src/app/services/ai-chat.service.ts b/web/src/app/services/ai-chat.service.ts
index 492b11c..9cd0f2e 100644
--- a/web/src/app/services/ai-chat.service.ts
+++ b/web/src/app/services/ai-chat.service.ts
@@ -1,7 +1,8 @@
import { Injectable, inject } from '@angular/core';
-import { Observable } from 'rxjs';
+import { Observable, Subscriber } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { LanguageService } from './language.service';
+import { parseSseStream, sseFetch } from '../shared/sse.util';
/**
* Un message d'une conversation IA (vue front).
@@ -105,108 +106,43 @@ export class AiChatService {
/** Plumbing SSE mutualisé entre les endpoints (Lore / Campaign / Session). */
private streamSse(endpoint: string, body: Record): Observable {
- return new Observable((subscriber) => {
- const controller = new AbortController();
-
- fetch(endpoint, {
+ return sseFetch(
+ endpoint,
+ {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'X-User-Language': this.language.current
},
- body: JSON.stringify(body),
- signal: controller.signal
- })
- .then(async (response) => {
- if (!response.ok || !response.body) {
- subscriber.error(new Error(`HTTP ${response.status}`));
- return;
- }
- await this.consumeSseStream(response.body, subscriber);
- })
- .catch((err) => {
- if (controller.signal.aborted) return; // annulation volontaire, silencieuse
- subscriber.error(err);
- });
-
- return () => controller.abort();
- });
+ body: JSON.stringify(body)
+ },
+ (responseBody, subscriber) => this.consumeSseStream(responseBody, subscriber)
+ );
}
- /**
- * Consomme un ReadableStream SSE ligne par ligne.
- * Format attendu (un événement = un bloc séparé par `\n\n`) :
- * event: done (optionnel, défaut = 'message')
- * data: {...} (une ou plusieurs lignes, concaténées avec '\n')
- * (séparateur d'événements)
- */
+ /** Mappe les événements SSE bruts vers les `ChatStreamEvent` typés. */
private async consumeSseStream(
body: ReadableStream,
- subscriber: { next: (e: ChatStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
+ subscriber: Subscriber
): Promise {
- const reader = body.getReader();
- const decoder = new TextDecoder('utf-8');
- let buffer = '';
-
- // Événement SSE en cours de construction (accumulé entre lignes vides).
- let currentEvent: string | null = null;
- let currentData = '';
-
- const dispatchCurrentEvent = () => {
- const eventName = currentEvent ?? 'message';
- // DEBUG jauge de contexte — à retirer une fois stabilisé.
- if (eventName !== 'message') {
- console.log('[AiChatService] SSE event:', eventName, 'data:', currentData);
- }
- if (eventName === 'error') {
- const message = this.safeParseMessage(currentData);
- subscriber.error(new Error(message));
- } else if (eventName === 'done') {
- subscriber.next({ type: 'done' });
- subscriber.complete();
- } else if (eventName === 'usage') {
- const usage = this.safeParseUsage(currentData);
- if (usage) subscriber.next({ type: 'usage', usage });
- } else {
- // Événement 'message' (défaut) : JSON {"token": "..."}
- const token = this.safeParseToken(currentData);
- if (token) subscriber.next({ type: 'token', value: token });
- }
- currentEvent = null;
- currentData = '';
- };
-
try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, { stream: true });
-
- // On découpe par lignes ; la dernière (potentiellement incomplète) reste dans buffer.
- let newlineIdx: number;
- while ((newlineIdx = buffer.indexOf('\n')) >= 0) {
- const line = buffer.slice(0, newlineIdx).replace(/\r$/, '');
- buffer = buffer.slice(newlineIdx + 1);
-
- if (line === '') {
- // Ligne vide = fin d'un événement SSE : on dispatch ce qu'on a accumulé.
- if (currentEvent !== null || currentData !== '') {
- dispatchCurrentEvent();
- }
- continue;
- }
- if (line.startsWith('event:')) {
- currentEvent = line.slice(6).trim();
- } else if (line.startsWith('data:')) {
- const chunk = line.slice(5).replace(/^ /, '');
- currentData = currentData ? `${currentData}\n${chunk}` : chunk;
- }
- // Autres champs SSE (id:, retry:) ignorés pour le MVP.
+ await parseSseStream(body, ({ event, data }) => {
+ if (event === 'error') {
+ subscriber.error(new Error(this.safeParseMessage(data)));
+ } else if (event === 'done') {
+ subscriber.next({ type: 'done' });
+ subscriber.complete();
+ } else if (event === 'usage') {
+ const usage = this.safeParseUsage(data);
+ if (usage) subscriber.next({ type: 'usage', usage });
+ } else {
+ // Événement 'message' (défaut) : JSON {"token": "..."}
+ const token = this.safeParseToken(data);
+ if (token) subscriber.next({ type: 'token', value: token });
}
- }
+ });
// Fin de stream côté réseau sans event: done explicite → on complete quand même.
- if (currentEvent !== null || currentData !== '') dispatchCurrentEvent();
subscriber.complete();
} catch (err) {
subscriber.error(err);
diff --git a/web/src/app/services/campaign-import.service.ts b/web/src/app/services/campaign-import.service.ts
index 122d0d3..ff41b8a 100644
--- a/web/src/app/services/campaign-import.service.ts
+++ b/web/src/app/services/campaign-import.service.ts
@@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
-import { Observable } from 'rxjs';
+import { Observable, Subscriber } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import {
CampaignImportApplyResult,
@@ -8,6 +8,7 @@ import {
CampaignImportStreamEvent
} from './campaign-import.model';
import { LanguageService } from './language.service';
+import { parseSseStream, sseFetch } from '../shared/sse.util';
/**
* Service HTTP pour l'import d'un PDF de campagne.
@@ -21,31 +22,17 @@ export class CampaignImportService {
constructor(private http: HttpClient, private translate: TranslateService, private language: LanguageService) {}
importStructureStream(campaignId: string, file: File): Observable {
- return new Observable((subscriber) => {
- const controller = new AbortController();
- const form = new FormData();
- form.append('file', file);
-
- fetch(`/api/campaigns/${campaignId}/import-structure/stream`, {
+ const form = new FormData();
+ form.append('file', file);
+ return sseFetch(
+ `/api/campaigns/${campaignId}/import-structure/stream`,
+ {
method: 'POST',
headers: { 'Accept': 'text/event-stream', 'X-User-Language': this.language.current },
- body: form,
- signal: controller.signal
- })
- .then(async (response) => {
- if (!response.ok || !response.body) {
- subscriber.error(new Error(`HTTP ${response.status}`));
- return;
- }
- await this.consumeSse(response.body, subscriber);
- })
- .catch((err) => {
- if (controller.signal.aborted) return;
- subscriber.error(err);
- });
-
- return () => controller.abort();
- });
+ body: form
+ },
+ (body, subscriber) => this.consumeSse(body, subscriber)
+ );
}
applyStructure(campaignId: string, proposal: CampaignImportProposal): Observable {
@@ -53,39 +40,33 @@ export class CampaignImportService {
`/api/campaigns/${campaignId}/import-structure/apply`, proposal);
}
- /** Décode un ReadableStream SSE et émet les évènements d'import typés. */
+ /** Mappe les évènements SSE bruts vers les évènements d'import typés. */
private async consumeSse(
body: ReadableStream,
- subscriber: { next: (e: CampaignImportStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
+ subscriber: Subscriber
): Promise {
- const reader = body.getReader();
- const decoder = new TextDecoder('utf-8');
- let buffer = '';
- let currentEvent: string | null = null;
- let currentData = '';
// Le flux s'est-il terminé PROPREMENT (évènement done ou error reçu) ?
// Sans ce suivi, une connexion coupée en plein import (timeout serveur,
// proxy, Core redémarré) terminait l'Observable en silence : barre de
// progression figée et aucun message pour l'utilisateur.
let terminated = false;
- const dispatch = () => {
- const name = currentEvent ?? 'message';
- if (name === 'error') {
+ const dispatch = ({ event, data }: { event: string; data: string }) => {
+ if (event === 'error') {
let message = this.translate.instant('services.importFailed');
- try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ }
+ try { message = (JSON.parse(data) as { message?: string }).message ?? message; } catch { /* défaut */ }
terminated = true;
subscriber.error(new Error(message));
- } else if (name === 'status') {
+ } else if (event === 'status') {
// Message d'attente lisible (fournisseur saturé, morceau re-découpé…).
try {
- const obj = JSON.parse(currentData) as { message?: string };
+ const obj = JSON.parse(data) as { message?: string };
if (obj.message) subscriber.next({ type: 'status', message: obj.message });
} catch { /* bloc malformé ignoré */ }
- } else if (name === 'progress' || name === 'done') {
+ } else if (event === 'progress' || event === 'done') {
try {
- const obj = JSON.parse(currentData);
- if (name === 'done') {
+ const obj = JSON.parse(data);
+ if (event === 'done') {
terminated = true;
subscriber.next({ type: 'done', arcs: obj.arcs ?? [], npcs: obj.npcs ?? [] });
subscriber.complete();
@@ -94,32 +75,10 @@ export class CampaignImportService {
}
} catch { /* bloc malformé ignoré */ }
}
- currentEvent = null;
- currentData = '';
};
try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, { stream: true });
- let idx: number;
- while ((idx = buffer.indexOf('\n')) >= 0) {
- const line = buffer.slice(0, idx).replace(/\r$/, '');
- buffer = buffer.slice(idx + 1);
- if (line === '') {
- if (currentEvent !== null || currentData !== '') dispatch();
- continue;
- }
- if (line.startsWith('event:')) {
- currentEvent = line.slice(6).trim();
- } else if (line.startsWith('data:')) {
- const chunk = line.slice(5).replace(/^ /, '');
- currentData = currentData ? `${currentData}\n${chunk}` : chunk;
- }
- }
- }
- if (currentEvent !== null || currentData !== '') dispatch();
+ await parseSseStream(body, dispatch);
if (!terminated) {
subscriber.error(new Error(
this.translate.instant('services.importInterrupted')));
diff --git a/web/src/app/services/game-system.service.ts b/web/src/app/services/game-system.service.ts
index a02fded..b05a481 100644
--- a/web/src/app/services/game-system.service.ts
+++ b/web/src/app/services/game-system.service.ts
@@ -1,9 +1,10 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
-import { Observable } from 'rxjs';
+import { Observable, Subscriber } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { GameSystem, GameSystemCreate, RulesImportResponse, RulesImportStreamEvent } from './game-system.model';
import { LanguageService } from './language.service';
+import { parseSseStream, sseFetch } from '../shared/sse.util';
/**
* Service HTTP pour les GameSystems (systèmes de JDR).
@@ -57,66 +58,46 @@ export class GameSystemService {
* subscription annule le fetch (AbortController).
*/
importRulesStream(file: File): Observable {
- return new Observable((subscriber) => {
- const controller = new AbortController();
- const form = new FormData();
- form.append('file', file);
-
- fetch(`${this.apiUrl}/import-rules/stream`, {
+ const form = new FormData();
+ form.append('file', file);
+ return sseFetch(
+ `${this.apiUrl}/import-rules/stream`,
+ {
method: 'POST',
headers: { 'Accept': 'text/event-stream', 'X-User-Language': this.language.current },
- body: form,
- signal: controller.signal
- })
- .then(async (response) => {
- if (!response.ok || !response.body) {
- subscriber.error(new Error(`HTTP ${response.status}`));
- return;
- }
- await this.consumeImportSse(response.body, subscriber);
- })
- .catch((err) => {
- if (controller.signal.aborted) return;
- subscriber.error(err);
- });
-
- return () => controller.abort();
- });
+ body: form
+ },
+ (body, subscriber) => this.consumeImportSse(body, subscriber)
+ );
}
- /** Décode un ReadableStream SSE et émet les évènements d'import typés. */
+ /** Mappe les évènements SSE bruts vers les évènements d'import typés. */
private async consumeImportSse(
body: ReadableStream,
- subscriber: { next: (e: RulesImportStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
+ subscriber: Subscriber
): Promise {
- const reader = body.getReader();
- const decoder = new TextDecoder('utf-8');
- let buffer = '';
- let currentEvent: string | null = null;
- let currentData = '';
// Le flux s'est-il terminé PROPREMENT (évènement done ou error reçu) ?
// Sans ce suivi, une connexion coupée en plein import (timeout serveur,
// proxy, Core redémarré) terminait l'Observable en silence : barre de
// progression figée et aucun message pour l'utilisateur.
let terminated = false;
- const dispatch = () => {
- const name = currentEvent ?? 'message';
- if (name === 'error') {
+ const dispatch = ({ event, data }: { event: string; data: string }) => {
+ if (event === 'error') {
let message = this.translate.instant('services.importFailed');
- try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ }
+ try { message = (JSON.parse(data) as { message?: string }).message ?? message; } catch { /* garde le défaut */ }
terminated = true;
subscriber.error(new Error(message));
- } else if (name === 'status') {
+ } else if (event === 'status') {
// Message d'attente lisible (fournisseur saturé, morceau re-découpé…).
try {
- const obj = JSON.parse(currentData) as { message?: string };
+ const obj = JSON.parse(data) as { message?: string };
if (obj.message) subscriber.next({ type: 'status', message: obj.message });
} catch { /* bloc malformé ignoré */ }
- } else if (name === 'progress' || name === 'done') {
+ } else if (event === 'progress' || event === 'done') {
try {
- const obj = JSON.parse(currentData);
- if (name === 'done') {
+ const obj = JSON.parse(data);
+ if (event === 'done') {
terminated = true;
subscriber.next({ type: 'done', ...obj });
subscriber.complete();
@@ -125,32 +106,10 @@ export class GameSystemService {
}
} catch { /* bloc malformé ignoré */ }
}
- currentEvent = null;
- currentData = '';
};
try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, { stream: true });
- let idx: number;
- while ((idx = buffer.indexOf('\n')) >= 0) {
- const line = buffer.slice(0, idx).replace(/\r$/, '');
- buffer = buffer.slice(idx + 1);
- if (line === '') {
- if (currentEvent !== null || currentData !== '') dispatch();
- continue;
- }
- if (line.startsWith('event:')) {
- currentEvent = line.slice(6).trim();
- } else if (line.startsWith('data:')) {
- const chunk = line.slice(5).replace(/^ /, '');
- currentData = currentData ? `${currentData}\n${chunk}` : chunk;
- }
- }
- }
- if (currentEvent !== null || currentData !== '') dispatch();
+ await parseSseStream(body, dispatch);
if (!terminated) {
subscriber.error(new Error(
this.translate.instant('services.importInterrupted')));
diff --git a/web/src/app/services/notebook.service.ts b/web/src/app/services/notebook.service.ts
index 5db80a0..6704705 100644
--- a/web/src/app/services/notebook.service.ts
+++ b/web/src/app/services/notebook.service.ts
@@ -4,6 +4,7 @@ import { Observable } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChatEvent } from './notebook.model';
import { LanguageService } from './language.service';
+import { parseSseStream } from '../shared/sse.util';
/**
* Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources,
@@ -88,20 +89,13 @@ export class NotebookService {
this.zone.run(() => subscriber.complete());
return;
}
- const reader = response.body.getReader();
- const decoder = new TextDecoder('utf-8');
- let buffer = '';
- let currentEvent: string | null = null;
- let currentData = '';
-
- const dispatch = () => {
- const name = currentEvent ?? 'message';
- if (name === 'token') {
- try { emit({ type: 'token', value: (JSON.parse(currentData) as { token?: string }).token ?? '' }); }
+ await parseSseStream(response.body, ({ event, data }) => {
+ if (event === 'token') {
+ try { emit({ type: 'token', value: (JSON.parse(data) as { token?: string }).token ?? '' }); }
catch { /* ignore */ }
- } else if (name === 'sources') {
+ } else if (event === 'sources') {
try {
- const o = JSON.parse(currentData) as { sources?: { source_id?: string; page?: number | null; score?: number }[] };
+ const o = JSON.parse(data) as { sources?: { source_id?: string; page?: number | null; score?: number }[] };
emit({
type: 'sources',
sources: (o.sources ?? []).map(s => ({
@@ -109,39 +103,19 @@ export class NotebookService {
}))
});
} catch { /* ignore */ }
- } else if (name === 'progress') {
+ } else if (event === 'progress') {
try {
- const o = JSON.parse(currentData) as { current?: number; total?: number };
+ const o = JSON.parse(data) as { current?: number; total?: number };
emit({ type: 'progress', current: o.current ?? 0, total: o.total ?? 0 });
} catch { /* ignore */ }
- } else if (name === 'done') {
+ } else if (event === 'done') {
emit({ type: 'done' });
- } else if (name === 'error') {
+ } else if (event === 'error') {
let msg = 'Erreur du chat.';
- try { msg = (JSON.parse(currentData) as { message?: string }).message ?? msg; } catch { /* garde */ }
+ try { msg = (JSON.parse(data) as { message?: string }).message ?? msg; } catch { /* garde */ }
emit({ type: 'error', message: msg });
}
- currentEvent = null;
- currentData = '';
- };
-
- while (true) {
- const { value, done } = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, { stream: true });
- let idx: number;
- while ((idx = buffer.indexOf('\n')) >= 0) {
- const line = buffer.slice(0, idx).replace(/\r$/, '');
- buffer = buffer.slice(idx + 1);
- if (line === '') { if (currentEvent !== null || currentData !== '') dispatch(); continue; }
- if (line.startsWith('event:')) currentEvent = line.slice(6).trim();
- else if (line.startsWith('data:')) {
- const chunk = line.slice(5).replace(/^ /, '');
- currentData = currentData ? `${currentData}\n${chunk}` : chunk;
- }
- }
- }
- if (currentEvent !== null || currentData !== '') dispatch();
+ });
this.zone.run(() => subscriber.complete());
} catch (err) {
if ((err as Error).name !== 'AbortError') {
diff --git a/web/src/app/shared/dice.utils.spec.ts b/web/src/app/shared/dice.utils.spec.ts
new file mode 100644
index 0000000..b51208d
--- /dev/null
+++ b/web/src/app/shared/dice.utils.spec.ts
@@ -0,0 +1,61 @@
+import { describe, it, expect } from 'vitest';
+import { DiceUtils } from './dice.utils';
+
+describe('DiceUtils.parse', () => {
+ it('parse une formule complète', () => {
+ expect(DiceUtils.parse('2d6')).toEqual({ count: 2, faces: 6 });
+ });
+
+ it('compte par défaut = 1 ("d20")', () => {
+ expect(DiceUtils.parse('d20')).toEqual({ count: 1, faces: 20 });
+ });
+
+ it('tolère espaces et casse', () => {
+ expect(DiceUtils.parse(' 1 D 100 ')).toEqual({ count: 1, faces: 100 });
+ });
+
+ it('rejette les formules invalides ou hors bornes', () => {
+ for (const f of ['', null, undefined, 'abc', '2x6', '0d6', '101d6', '1d1', '1d20000']) {
+ expect(DiceUtils.parse(f as string)).toBeNull();
+ }
+ });
+});
+
+describe('DiceUtils.roll', () => {
+ it('renvoie des jets dans la plage et un total = somme', () => {
+ const r = DiceUtils.roll('3d6');
+ expect(r).not.toBeNull();
+ expect(r!.rolls).toHaveLength(3);
+ for (const v of r!.rolls) {
+ expect(v).toBeGreaterThanOrEqual(1);
+ expect(v).toBeLessThanOrEqual(6);
+ }
+ expect(r!.total).toBe(r!.rolls.reduce((a, b) => a + b, 0));
+ expect(r!.total).toBeGreaterThanOrEqual(3);
+ expect(r!.total).toBeLessThanOrEqual(18);
+ });
+
+ it('null si formule invalide', () => {
+ expect(DiceUtils.roll('nope')).toBeNull();
+ });
+});
+
+describe('DiceUtils.totalRange', () => {
+ it('2d6 -> {min:2, max:12}', () => {
+ expect(DiceUtils.totalRange('2d6')).toEqual({ min: 2, max: 12 });
+ });
+
+ it('null si invalide', () => {
+ expect(DiceUtils.totalRange('x')).toBeNull();
+ });
+});
+
+describe('DiceUtils.randomInt', () => {
+ it('reste dans [min, max] sur de nombreux tirages', () => {
+ for (let i = 0; i < 200; i++) {
+ const v = DiceUtils.randomInt(1, 6);
+ expect(v).toBeGreaterThanOrEqual(1);
+ expect(v).toBeLessThanOrEqual(6);
+ }
+ });
+});
diff --git a/web/src/app/shared/sse.util.spec.ts b/web/src/app/shared/sse.util.spec.ts
new file mode 100644
index 0000000..fbd9a9f
--- /dev/null
+++ b/web/src/app/shared/sse.util.spec.ts
@@ -0,0 +1,112 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { parseSseStream, sseFetch, SseEvent } from './sse.util';
+
+/** Construit un ReadableStream à partir d'une chaîne (un seul chunk). */
+function streamFrom(text: string): ReadableStream {
+ const bytes = new TextEncoder().encode(text);
+ return new ReadableStream({
+ start(controller) {
+ controller.enqueue(bytes);
+ controller.close();
+ },
+ });
+}
+
+async function collect(text: string): Promise {
+ const events: SseEvent[] = [];
+ await parseSseStream(streamFrom(text), (e) => events.push(e));
+ return events;
+}
+
+describe('parseSseStream', () => {
+ it('parse un événement nommé avec sa data', async () => {
+ expect(await collect('event: token\ndata: {"token":"hi"}\n\n'))
+ .toEqual([{ event: 'token', data: '{"token":"hi"}' }]);
+ });
+
+ it("nomme l'événement par défaut 'message'", async () => {
+ expect(await collect('data: {"token":"x"}\n\n'))
+ .toEqual([{ event: 'message', data: '{"token":"x"}' }]);
+ });
+
+ it('sépare plusieurs événements', async () => {
+ const events = await collect('event: a\ndata: 1\n\nevent: b\ndata: 2\n\n');
+ expect(events).toEqual([
+ { event: 'a', data: '1' },
+ { event: 'b', data: '2' },
+ ]);
+ });
+
+ it('concatène les lignes data multiples avec des \\n', async () => {
+ const events = await collect('data: ligne1\ndata: ligne2\n\n');
+ expect(events[0].data).toBe('ligne1\nligne2');
+ });
+
+ it('gère les fins de ligne CRLF', async () => {
+ expect(await collect('event: x\r\ndata: y\r\n\r\n'))
+ .toEqual([{ event: 'x', data: 'y' }]);
+ });
+
+ it('ignore les lignes de commentaire keep-alive (`: ...`)', async () => {
+ expect(await collect(': keep-alive\n\ndata: ok\n\n'))
+ .toEqual([{ event: 'message', data: 'ok' }]);
+ });
+
+ it('dispatche un bloc résiduel en fin de flux sans ligne vide finale', async () => {
+ // Lignes terminées par \n mais pas de ligne vide séparatrice finale.
+ expect(await collect('event: done\ndata: {}\n'))
+ .toEqual([{ event: 'done', data: '{}' }]);
+ });
+
+ it('ne dispatche rien sur un flux vide', async () => {
+ expect(await collect('')).toEqual([]);
+ });
+});
+
+describe('sseFetch', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("consomme le corps et expose les évènements jusqu'à complétion", async () => {
+ const body = streamFrom('event: done\ndata: {}\n\n');
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status: 200 })));
+
+ const seen: string[] = [];
+ await new Promise((resolve, reject) => {
+ sseFetch('/x', { method: 'POST' }, async (b, sub) => {
+ await parseSseStream(b, (e) => seen.push(e.event));
+ sub.next('mapped');
+ sub.complete();
+ }).subscribe({ next: (v) => seen.push(v), complete: resolve, error: reject });
+ });
+
+ expect(seen).toContain('done');
+ expect(seen).toContain('mapped');
+ });
+
+ it('émet une erreur HTTP si la réponse est non-ok', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 500 })));
+
+ await expect(
+ new Promise((resolve, reject) => {
+ sseFetch('/x', {}, async () => undefined).subscribe({
+ error: reject,
+ complete: resolve,
+ });
+ }),
+ ).rejects.toThrow('HTTP 500');
+ });
+
+ it('annule le fetch quand on se désabonne', async () => {
+ let abortedSignal: AbortSignal | undefined;
+ vi.stubGlobal('fetch', vi.fn((_url: string, init: RequestInit) => {
+ abortedSignal = init.signal as AbortSignal;
+ return new Promise(() => { /* ne résout jamais : on teste l'annulation */ });
+ }));
+
+ const sub = sseFetch('/x', {}, async () => undefined).subscribe();
+ sub.unsubscribe();
+ expect(abortedSignal?.aborted).toBe(true);
+ });
+});
diff --git a/web/src/app/shared/sse.util.ts b/web/src/app/shared/sse.util.ts
new file mode 100644
index 0000000..b39aff7
--- /dev/null
+++ b/web/src/app/shared/sse.util.ts
@@ -0,0 +1,110 @@
+import { Observable, Subscriber } from 'rxjs';
+
+/**
+ * Plumbing SSE mutualisé entre les services qui streament depuis le backend
+ * (chat IA, import de campagne, import de règles, chat d'atelier).
+ *
+ * On n'utilise pas `EventSource` (API navigateur native) : elle ne supporte que
+ * GET sans body, alors qu'on a besoin de POST (+ multipart). On fait donc un
+ * `fetch()` dont on décode le `ReadableStream` ligne par ligne.
+ */
+
+/** Un événement SSE complet (bloc séparé par une ligne vide). */
+export interface SseEvent {
+ /** Nom de l'événement (`event:` du flux), ou `'message'` par défaut. */
+ event: string;
+ /** Charge utile (`data:`) ; les lignes multiples sont concaténées par `\n`. */
+ data: string;
+}
+
+/**
+ * Décode un `ReadableStream` SSE et invoque `onEvent` pour chaque bloc complet.
+ *
+ * Format attendu (un événement = un bloc terminé par une ligne vide) :
+ * event: done (optionnel, défaut = 'message')
+ * data: {...} (une ou plusieurs lignes, concaténées avec '\n')
+ * (séparateur d'événements)
+ *
+ * Gère le buffer partiel entre deux lectures, les fins de ligne `\r\n`, et
+ * dispatche un éventuel bloc résiduel en fin de flux (réseau coupé sans ligne
+ * vide finale). Ne complète/erreur PAS d'observable : l'appelant mappe chaque
+ * `SseEvent` vers son type métier et décide de la terminaison.
+ */
+export async function parseSseStream(
+ body: ReadableStream,
+ onEvent: (ev: SseEvent) => void
+): Promise {
+ const reader = body.getReader();
+ const decoder = new TextDecoder('utf-8');
+ let buffer = '';
+ let currentEvent: string | null = null;
+ let currentData = '';
+
+ const flush = () => {
+ if (currentEvent === null && currentData === '') return;
+ onEvent({ event: currentEvent ?? 'message', data: currentData });
+ currentEvent = null;
+ currentData = '';
+ };
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+
+ // On découpe par lignes ; la dernière (potentiellement incomplète) reste dans buffer.
+ let newlineIdx: number;
+ while ((newlineIdx = buffer.indexOf('\n')) >= 0) {
+ const line = buffer.slice(0, newlineIdx).replace(/\r$/, '');
+ buffer = buffer.slice(newlineIdx + 1);
+
+ if (line === '') {
+ flush(); // ligne vide = fin d'un événement SSE
+ continue;
+ }
+ if (line.startsWith('event:')) {
+ currentEvent = line.slice(6).trim();
+ } else if (line.startsWith('data:')) {
+ const chunk = line.slice(5).replace(/^ /, '');
+ currentData = currentData ? `${currentData}\n${chunk}` : chunk;
+ }
+ // Autres champs SSE (id:, retry:) ignorés.
+ }
+ }
+ // Fin de stream sans ligne vide finale : on dispatche le dernier bloc accumulé.
+ flush();
+}
+
+/**
+ * Ouvre un flux SSE via `fetch()` et expose ses événements dans un `Observable`.
+ *
+ * Annuler la souscription annule proprement le fetch (AbortController) ; une
+ * erreur réseau survenue APRÈS une annulation volontaire est ignorée. Le rappel
+ * `consume` reçoit le corps de la réponse et le subscriber : il mappe les
+ * événements bruts vers le type métier `T` et complète/erreur le subscriber
+ * (typiquement via {@link parseSseStream}).
+ */
+export function sseFetch(
+ url: string,
+ init: RequestInit,
+ consume: (body: ReadableStream, subscriber: Subscriber) => Promise
+): Observable {
+ return new Observable((subscriber) => {
+ const controller = new AbortController();
+
+ fetch(url, { ...init, signal: controller.signal })
+ .then(async (response) => {
+ if (!response.ok || !response.body) {
+ subscriber.error(new Error(`HTTP ${response.status}`));
+ return;
+ }
+ await consume(response.body, subscriber);
+ })
+ .catch((err) => {
+ if (controller.signal.aborted) return; // annulation volontaire, silencieuse
+ subscriber.error(err);
+ });
+
+ return () => controller.abort();
+ });
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
index 8c5be9f..680f171 100644
--- a/web/tsconfig.json
+++ b/web/tsconfig.json
@@ -30,6 +30,8 @@
},
"exclude": [
"e2e/**/*",
- "playwright.config.ts"
+ "playwright.config.ts",
+ "vitest.config.ts",
+ "src/**/*.spec.ts"
]
}
diff --git a/web/vitest.config.ts b/web/vitest.config.ts
new file mode 100644
index 0000000..06addcc
--- /dev/null
+++ b/web/vitest.config.ts
@@ -0,0 +1,40 @@
+import { defineConfig } from 'vitest/config';
+
+/**
+ * Runner des tests UNITAIRES de logique PURE (TypeScript sans TestBed Angular) :
+ * helpers/utils et services dont on teste les fonctions pures. Exécution en Node
+ * (rapide, pas de navigateur). Les specs sont en `*.spec.ts` à côté du code.
+ *
+ * Lancement : `npm run test:unit` ; couverture : `npm run test:unit:coverage`
+ * (rapport HTML dans coverage/ — l'équivalent JaCoCo).
+ *
+ * Hors du périmètre `ng build` (les .spec.ts sont exclus dans tsconfig.json).
+ */
+export default defineConfig({
+ test: {
+ environment: 'node',
+ include: ['src/**/*.spec.ts'],
+ globals: true,
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'html'],
+ // Périmètre de couverture = les fichiers effectivement couverts par des
+ // specs. Ajouter ici chaque module au fur et à mesure qu'on le teste.
+ include: [
+ 'src/app/shared/sse.util.ts',
+ 'src/app/shared/dice.utils.ts',
+ 'src/app/campaigns/campaign-tree.helper.ts',
+ ],
+ // Plancher ANTI-RÉGRESSION (sous les valeurs actuelles : ~75% lignes).
+ // campaign-tree.helper tire la moyenne vers le bas (loadCampaignTreeData /
+ // buildCampaignSidebarConfig nécessitent des services Angular → non
+ // unit-testables ici). À remonter à mesure que la couverture progresse.
+ thresholds: {
+ statements: 70,
+ lines: 70,
+ branches: 70,
+ functions: 45,
+ },
+ },
+ },
+});