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
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:
61
web/src/app/shared/dice.utils.spec.ts
Normal file
61
web/src/app/shared/dice.utils.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
112
web/src/app/shared/sse.util.spec.ts
Normal file
112
web/src/app/shared/sse.util.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
110
web/src/app/shared/sse.util.ts
Normal file
110
web/src/app/shared/sse.util.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user