All checks were successful
mode séance, battlemaps multiples et export Foundry ciblé
- Quêtes (Niveau 1) : entité Quest orthogonale à l'arbre, rattachée à un arc HUB
ou libre (migrations V9-V12, V18, V20 ; réconciliation des jumeaux V10).
Fusion quête/conteneur dans la sidebar, progression par partie (statuts
disponible/en cours/terminée), et quêtes libres avec espace de scènes créé
automatiquement (arc technique « Quêtes libres », V21 : levée de la
contrainte arcs.type héritée du baseline).
- Guidage (Pilier B) : bilan de préparation 100 % dérivé (règles arc/chapitre/
scène/quête), panneau « Prochaines étapes » avec boutons « Corriger », et
pastilles détaillées (tooltip des manques) dans l'arbre.
- Assistant IA (Pilier A) : étoffage champ par champ (scène, chapitre, arc) et
brouillons de scènes en propose→applique (brain : narrative-fields,
scene-drafts).
- Horloges & menaces (V15-V17) : clocks à segments avec déclencheurs, fronts.
- Mode séance : préparation de séance (readiness + quêtes dispo), scène
épinglée (V19), récap « précédemment » (brain : session-recap), onglet
« Partie » du panneau de référence, graphe amélioré (pan/zoom, éditeur de
liens).
- Perf : endpoint agrégé GET /api/campaigns/{id}/tree — la sidebar charge en
1 requête au lieu de ~15.
- Battlemaps multiples par scène (variantes jour/nuit, étages…) : liste JSON
étiquetée (V22, reprise automatique de la carte existante), rendu PDF avec
légendes, export/import rétro-compatible.
- Export Foundry ciblé : modale de périmètre (cartes+ennemis / journaux /
tables), bundle filtré côté serveur (zip allégé), module Foundry à jour
(respect du périmètre + une Scene Foundry par variante de carte,
rétro-compatible anciens bundles).
169 lines
9.1 KiB
TypeScript
169 lines
9.1 KiB
TypeScript
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> = {}): CampaignTreeData {
|
|
return {
|
|
arcs: [], chaptersByArc: {}, scenesByChapter: {},
|
|
npcs: [], randomTables: [], enemies: [],
|
|
...partial,
|
|
};
|
|
}
|
|
|
|
describe('buildCampaignTree', () => {
|
|
it('produit les nœuds fixes (quêtes, PNJ, ennemis, tables, ateliers, catalogues, import) quand vide', () => {
|
|
const tree = buildCampaignTree('c1', data(), t);
|
|
expect(tree.map((n) => n.id)).toEqual([
|
|
'quests-root', 'npcs-root', 'enemies-root', 'random-tables-root',
|
|
'notebooks-root', 'item-catalogs-root', 'import-pdf-root',
|
|
]);
|
|
});
|
|
|
|
it('trie les arcs par ordre manuel (champ order, réagencé au glisser-déposer)', () => {
|
|
const arcs = [
|
|
{ id: 'a', name: 'Final', order: 2 },
|
|
{ id: 'b', name: 'Voyage', order: 1 },
|
|
{ id: 'c', name: 'Intro', order: 0 },
|
|
];
|
|
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(['Intro', 'Voyage', 'Final']);
|
|
});
|
|
|
|
it('imbrique chapitres et scènes', () => {
|
|
const tree = buildCampaignTree('camp', data({
|
|
arcs: [{ id: 'a1', name: 'Arc', type: 'LINEAR' }] as never,
|
|
// 2 chapitres pour éviter le mode plat (Niveau 0), qui masque arc + chapitre
|
|
// quand il n'y a qu'un seul arc d'un seul chapitre.
|
|
chaptersByArc: { a1: [{ id: 'c1', name: 'Chap' }, { id: 'c2', name: 'Chap 2' }] } as never,
|
|
scenesByChapter: { c1: [{ id: 's1', name: 'Scene' }] } as never,
|
|
}), t);
|
|
const arc = tree.find((n) => n.id === 'arc-a1')!;
|
|
const chapter = arc.children!.find((c) => c.id === 'chapter-c1')!;
|
|
expect(chapter.children![0].id).toBe('scene-s1');
|
|
});
|
|
|
|
it('regroupe les PNJ par dossier et place les non classés dans un pseudo-dossier', () => {
|
|
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']);
|
|
// Les non classés vont dans un pseudo-dossier « Sans dossier » (et non en enfants
|
|
// directs) : tous les dossiers restent FRÈRES → glisser-déposer inter-dossiers fiable.
|
|
const none = npcsRoot.children!.find((c) => c.id === 'npc-folder-__none__')!;
|
|
expect(none.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, folder: 'Cave' }] as never,
|
|
}), t);
|
|
const enemiesRoot = tree.find((n) => n.id === 'enemies-root')!;
|
|
const folder = enemiesRoot.children!.find((c) => c.id === 'enemy-folder-Cave')!;
|
|
expect(folder.children![0].meta).toBe('Niv. 3');
|
|
});
|
|
|
|
it('arc LINÉAIRE = chapitres ; arc HUB = quêtes (bouton « nouvelle quête » uniquement)', () => {
|
|
const hub = buildCampaignTree('c', data({
|
|
arcs: [{ id: 'a', name: 'Hub', type: 'HUB' }] as never,
|
|
quests: [{ id: 'q1', name: 'Enquête', arcId: 'a' }, { id: 'q2', name: 'Transverse', arcId: null }] as never,
|
|
}), t);
|
|
const hubArc = hub.find((n) => n.id === 'arc-a')!;
|
|
expect(hubArc.children!.some((c) => c.id === 'quest-q1')).toBe(true); // rattachée → enfant
|
|
expect(hubArc.children!.some((c) => c.id === 'quest-q2')).toBe(false); // transverse → pas ici
|
|
// HUB : on ne crée QUE des quêtes (pas de « nouveau chapitre »).
|
|
expect(hubArc.createActions!.map((a) => a.label)).toEqual(['campaignTree.newQuest']);
|
|
expect(hubArc.createActions![0].queryParams).toEqual({ arcId: 'a' });
|
|
|
|
// LINEAR : uniquement des chapitres ; une quête même arcId=arc n'apparaît PAS sous l'arc.
|
|
const linear = buildCampaignTree('c', data({
|
|
arcs: [{ id: 'a', name: 'Lin', type: 'LINEAR' }] as never,
|
|
quests: [{ id: 'q3', name: 'X', arcId: 'a' }] as never,
|
|
}), t);
|
|
const linArc = linear.find((n) => n.id === 'arc-a')!;
|
|
expect(linArc.children!.some((c) => c.id === 'quest-q3')).toBe(false);
|
|
expect(linArc.createActions!.map((a) => a.label)).toEqual(['campaignTree.newChapter']);
|
|
|
|
// Le nœud « Quêtes » dédié conserve son action de création.
|
|
expect(hub.find((n) => n.id === 'quests-root')!.createActions![0].label).toBe('campaignTree.newQuest');
|
|
});
|
|
|
|
it('pastilles : un manque de QUÊTE n\'allume PAS une scène/PNJ portant le même id (séquences par table)', () => {
|
|
// Chaque table a sa propre séquence IDENTITY : quête id 7 et scène id 7 coexistent.
|
|
const gaps = [{
|
|
entityType: 'QUEST', entityId: '7', entityName: 'Q', ruleId: 'QUEST-001-NO-NODES',
|
|
message: 'm', severity: 'BLOCKING', arcId: null, chapterId: null,
|
|
}];
|
|
const tree = buildCampaignTree('c', data({
|
|
arcs: [{ id: 'a1', name: 'Arc', type: 'LINEAR' }] as never,
|
|
chaptersByArc: { a1: [{ id: 'c1', name: 'Chap' }, { id: 'c2', name: 'Chap 2' }] } as never,
|
|
scenesByChapter: { c1: [{ id: '7', name: 'Scène homonyme' }] } as never,
|
|
npcs: [{ id: '7', name: 'PNJ homonyme' }] as never,
|
|
quests: [{ id: '7', name: 'Q', arcId: null }] as never,
|
|
}), t, gaps as never);
|
|
const arc = tree.find((n) => n.id === 'arc-a1')!;
|
|
const scene = arc.children!.find((c) => c.id === 'chapter-c1')!.children!.find((s) => s.id === 'scene-7')!;
|
|
expect(scene.statusDot).toBeUndefined(); // pas de faux positif
|
|
expect(tree.find((n) => n.id === 'npcs-root')!.statusDot).toBeUndefined(); // idem PNJ
|
|
expect(tree.find((n) => n.id === 'quests-root')!.statusDot).toBe('blocking'); // le vrai coupable
|
|
});
|
|
|
|
it('arc HUB : une quête FUSIONNE avec son chapitre jumeau (un seul nœud, scènes dessous)', () => {
|
|
// Héritage V10 : la quête référence son chapitre jumeau via un nœud CHAPTER.
|
|
const tree = buildCampaignTree('c', data({
|
|
arcs: [{ id: 'a', name: 'Hub', type: 'HUB' }] as never,
|
|
chaptersByArc: { a: [{ id: 'c1', name: 'Jumeau' }, { id: 'c2', name: 'Chapitre libre' }] } as never,
|
|
scenesByChapter: { c1: [{ id: 's1', name: 'Scène du jumeau' }] } as never,
|
|
quests: [{ id: 'q1', name: 'La quête', arcId: 'a', nodes: [{ nodeType: 'CHAPTER', nodeId: 'c1', order: 0 }] }] as never,
|
|
}), t);
|
|
const hubArc = tree.find((n) => n.id === 'arc-a')!;
|
|
// La quête absorbe le jumeau : pas de nœud chapter-c1, ses scènes vivent sous quest-q1.
|
|
expect(hubArc.children!.some((c) => c.id === 'chapter-c1')).toBe(false);
|
|
const questNode = hubArc.children!.find((c) => c.id === 'quest-q1')!;
|
|
expect(questNode.children!.some((c) => c.id === 'scene-s1')).toBe(true);
|
|
expect(questNode.dropParentId).toBe('c1'); // le jumeau reste le conteneur des scènes
|
|
// Le chapitre NON consommé reste affiché tel quel (contenu mixte/historique).
|
|
expect(hubArc.children!.some((c) => c.id === 'chapter-c2')).toBe(true);
|
|
});
|
|
|
|
it("l'arc SYSTEM (« Quêtes libres ») est masqué de la narration", () => {
|
|
const tree = buildCampaignTree('c', data({
|
|
arcs: [
|
|
{ id: 'a1', name: 'Arc visible', type: 'LINEAR' },
|
|
{ id: 'sys', name: 'Quêtes libres', type: 'SYSTEM' },
|
|
] as never,
|
|
chaptersByArc: {
|
|
a1: [{ id: 'c1', name: 'Chap' }, { id: 'c2', name: 'Chap 2' }],
|
|
sys: [{ id: 'cf', name: 'Conteneur' }],
|
|
} as never,
|
|
}), t);
|
|
expect(tree.some((n) => n.id === 'arc-sys')).toBe(false); // pas dans l'arbre
|
|
expect(tree.some((n) => n.id === 'arc-a1')).toBe(true);
|
|
});
|
|
|
|
it('quête LIBRE : fusionnée sous « Quêtes » avec les scènes de son conteneur SYSTEM et « + scène »', () => {
|
|
const tree = buildCampaignTree('c', data({
|
|
arcs: [{ id: 'sys', name: 'Quêtes libres', type: 'SYSTEM' }] as never,
|
|
chaptersByArc: { sys: [{ id: 'cf', name: 'Libre', arcId: 'sys' }] } as never,
|
|
scenesByChapter: { cf: [{ id: 's1', name: 'Scène à la volée' }] } as never,
|
|
quests: [{ id: 'q1', name: 'Libre', arcId: null, nodes: [{ nodeType: 'CHAPTER', nodeId: 'cf', order: 0 }] }] as never,
|
|
}), t);
|
|
const questsRoot = tree.find((n) => n.id === 'quests-root')!;
|
|
const questNode = questsRoot.children!.find((c) => c.id === 'quest-q1')!;
|
|
expect(questNode.children!.some((c) => c.id === 'scene-s1')).toBe(true);
|
|
expect(questNode.dropParentId).toBe('cf'); // le conteneur reçoit les drops de scènes
|
|
// « + scène » pointe vers la création de scène DANS le conteneur (arc SYSTEM).
|
|
expect(questNode.createActions![0].route).toBe('/campaigns/c/arcs/sys/chapters/cf/scenes/create');
|
|
});
|
|
});
|