Refacto du code coté Python, java et coté angular afin de mieux séparer les responsabilité et d'avoir moins de répétitivité dans le code.
Some checks failed
E2E Tests / e2e (push) Waiting to run
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 26s
Build & Push Images / tests (push) Failing after 13s
Build & Push Images / build (brain) (push) Has been skipped
Build & Push Images / build (core) (push) Has been skipped
Build & Push Images / build (web) (push) Has been skipped
Build & Push Images / build-switcher (push) Has been skipped
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 1m12s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 1m28s

Mise en place de tests unitaires coté Python et Angular
Mise en place de la couverture de test directement dans le workflow : le programme ne build pas si jamais un test échoue
Passage en v0.16.2 en conséquence
This commit is contained in:
2026-06-18 15:59:10 +02:00
parent eb78a75621
commit 4d049274f9
68 changed files with 4433 additions and 1360 deletions

View File

@@ -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> = {}): 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');
});
});

View File

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

View File

@@ -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<string, unknown>): Observable<ChatStreamEvent> {
return new Observable<ChatStreamEvent>((subscriber) => {
const controller = new AbortController();
fetch(endpoint, {
return sseFetch<ChatStreamEvent>(
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')
* <ligne vide> (séparateur d'événements)
*/
/** Mappe les événements SSE bruts vers les `ChatStreamEvent` typés. */
private async consumeSseStream(
body: ReadableStream<Uint8Array>,
subscriber: { next: (e: ChatStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
subscriber: Subscriber<ChatStreamEvent>
): Promise<void> {
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);

View File

@@ -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<CampaignImportStreamEvent> {
return new Observable<CampaignImportStreamEvent>((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<CampaignImportStreamEvent>(
`/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<CampaignImportApplyResult> {
@@ -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<Uint8Array>,
subscriber: { next: (e: CampaignImportStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
subscriber: Subscriber<CampaignImportStreamEvent>
): Promise<void> {
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')));

View File

@@ -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<RulesImportStreamEvent> {
return new Observable<RulesImportStreamEvent>((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<RulesImportStreamEvent>(
`${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<Uint8Array>,
subscriber: { next: (e: RulesImportStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
subscriber: Subscriber<RulesImportStreamEvent>
): Promise<void> {
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')));

View File

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

View File

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

View File

@@ -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<Uint8Array> {
const bytes = new TextEncoder().encode(text);
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
});
}
async function collect(text: string): Promise<SseEvent[]> {
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<void>((resolve, reject) => {
sseFetch<string>('/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<void>((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);
});
});

View File

@@ -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')
* <ligne vide> (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<Uint8Array>,
onEvent: (ev: SseEvent) => void
): Promise<void> {
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<T>(
url: string,
init: RequestInit,
consume: (body: ReadableStream<Uint8Array>, subscriber: Subscriber<T>) => Promise<void>
): Observable<T> {
return new Observable<T>((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();
});
}