@mostajs/reporting 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog — @mostajs/reporting
2
+
3
+ ## [0.1.0] — 2026-06-18
4
+ ### Added
5
+ - Agrégateurs purs (`count/sum/avg/min/max/distinctCount/rate/groupBy`) — GENERALIZE de MostaGare `metrics-calculator`.
6
+ - Périodes (`periodRange` jour/mois/trimestre/an, `previousRange`, `inRange`).
7
+ - `createReporting`/`defineReport` : rapports déclaratifs sur sources injectées, `run` (métriques + count + groups), comparaison période précédente (delta), sucres `daily/monthly/quarterly/annual`, `export` (compose `@mostajs/file-export`).
8
+ - Tests `test-scripts/unit` (8 verts) + exemple §12 (`examples/kpis` : KPIs réels sur @mostajs/users + @mostajs/allocations).
9
+
10
+ ### Notes
11
+ - EXTRACTION du patron MostaGare `AnalyticsReportService`. DB-agnostique (sources injectées). Consommateurs : P1/P2/P3.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # @mostajs/reporting
2
+
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Licence** : AGPL-3.0-or-later · **Statut** : 0.1.0 (8 tests verts)
4
+
5
+ > Moteur de reporting **générique** : KPIs + rapports périodiques + comparaison période précédente + export.
6
+ > **DB-agnostique** (sources injectées). Consommé par P1 Hadhinat, P2 ATC, P3 ASSO-SEL. Compose `@mostajs/file-export`.
7
+
8
+ ## Exemple
9
+ ```js
10
+ import { createReporting, defineReport, count, rate } from '@mostajs/reporting';
11
+ const reporting = createReporting({
12
+ sources: { units: () => db.units.all() },
13
+ reports: [defineReport({ name:'occupancy', source:'units', metrics:{
14
+ total: count(), occupes: count(u=>u.status==='assigned'), taux: rate(u=>u.status==='assigned')
15
+ }})],
16
+ });
17
+ const r = await reporting.monthly('occupancy', { comparePrevious:true });
18
+ // → { period, count, metrics:{total,occupes,taux}, comparison:{ delta } }
19
+ ```
20
+
21
+ ## Lancer
22
+ ```bash
23
+ node examples/kpis/run.mjs
24
+ node test-scripts/unit/reporting.test.mjs
25
+ ```
26
+ API & types : `llms.txt`. Proposition/plan : `docs/`.
@@ -0,0 +1,34 @@
1
+ # @mostajs/reporting — Proposition §4 + Plan de dev (EXTRACTION)
2
+
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com>
4
+ **Date** : 2026-06-18
5
+ **Statut** : proposition à discuter (cas C — DEVRULES §4/§9 #3).
6
+ **Priorité** : **P0** — module générique le plus rentable (consommateurs **P1 Hadhinat, P2 ATC, P3 ASSO-SEL** + tout back-office).
7
+ **Principe** : **EXTRACTION, NE PAS REDÉVELOPPER** — généralise la couche analytics de `SolutionCh/MostaGare`.
8
+
9
+ ## 4.1 Besoin
10
+ Agréger des données métier en **KPIs** + **rapports périodiques** (jour/mois/trimestre/an) + **comparaison à la période précédente** + **export** (PDF/CSV/Excel). Besoin **transverse** réclamé par les 3 projets ; aujourd'hui réimplémenté (MostaGare `AnalyticsReportService`/`metrics-calculator`).
11
+
12
+ ## 4.2 Règle d'or
13
+ `@mostajs/mjs-metrics` = test de charge (≠ KPI métier) ; `@mostajs/audit` = source d'événements (pas l'agrégation) ; `file-export` = export (pas le calcul). → **cas C** : moteur d'agrégation générique, **DB-agnostique** (sources injectées), composant `file-export` pour l'export.
14
+
15
+ ## 4.3 Périmètre
16
+ Moteur : **agrégateurs** (count/sum/avg/rate/distinct/min/max/groupBy), **périodes** (ranges + période précédente), **rapports** déclaratifs (`defineReport`), **comparaison**, **export** (délégué `@mostajs/file-export`). Pas de stockage : lit via **sources injectées** (`@mostajs/data-plug`/`audit`/`users`/`allocations`…). Pas d'UI (dashboards = couche app/`reporting-ui` ultérieure).
17
+
18
+ ## Carte d'extraction (source MostaGare → cible)
19
+ | Source | Cible `src/` | Action |
20
+ |---|---|---|
21
+ | `metrics-calculator.ts` ($sum/$avg/$group, errorRate/successRate) | `aggregators.js` | GENERALIZE en fns pures `count/sum/avg/rate/distinct/min/max/groupBy` |
22
+ | `AnalyticsReportService.ts` (generateXReport(start,end,filters), period, comparaison période précédente, insights) | `reporting.js` (`createReporting`, `defineReport`, `run`) | EXTRACT le patron rapport+période+comparaison |
23
+ | `AgentDailyMetrics.ts` (totalActions/rate/period) | exemples de métriques | PATTERN |
24
+ | `file-export` `exportDoc(format, doc)` | `export()` | COMPOSE |
25
+
26
+ ## Plan de dev (jalons)
27
+ | v | Contenu |
28
+ |---|---|
29
+ | 0.1.0 | `aggregators` + `periods` + `createReporting`/`defineReport`/`run` (sources mémoire) + tests + exemple |
30
+ | 0.2.0 | comparaison période précédente + `groupBy` multi-niveaux + helpers daily/monthly/quarterly/annual |
31
+ | 0.3.0 | `export()` (compose `@mostajs/file-export`) + adaptateurs `@mostajs/data-plug` |
32
+ | 1.0.0 | 14 livrables §9 + portes §11 + exemple §12 + article #17 |
33
+
34
+ **Composition (§10)** : `data-plug`/`audit`/`users`/`allocations` (sources) · `file-export` (export). **Portes** : §11.1 (bloquante) ; §11.2/§11.3 informatives (données agrégées → minimisation).
@@ -0,0 +1,60 @@
1
+ // Exemple §12 — KPIs réels : reporting agrège les données @mostajs/users + @mostajs/allocations.
2
+ // node examples/kpis/run.mjs
3
+ import assert from 'node:assert/strict';
4
+ import { createReporting, defineReport, count, rate, sum } from '../../src/index.js';
5
+ import { inRange } from '../../src/periods.js';
6
+ import { createUsers, createMemoryRepositories as userRepos } from '../../../mosta-users-stack/mosta-users/src/index.js';
7
+ import { createAllocations, createMemoryRepositories as allocRepos } from '../../../mosta-allocations/src/index.js';
8
+
9
+ // --- données via les vrais modules ---
10
+ const users = createUsers({ repositories: userRepos() });
11
+ const gym = createAllocations({ repositories: allocRepos() });
12
+ const a = await users.create({ firstName: 'Inès' }); await users.enrollFace(a.id, [1, 2, 3]);
13
+ const b = await users.create({ firstName: 'Karim' });
14
+ const c = await users.create({ firstName: 'Sami' }); await users.disable(c.id);
15
+ const u1 = await gym.units.create({ code: 'V1', resourceType: 'gym-locker' });
16
+ await gym.units.create({ code: 'V2', resourceType: 'gym-locker' });
17
+ await gym.alloc.assign(u1.id, a.id);
18
+
19
+ // --- reporting : sources injectées + rapports déclaratifs ---
20
+ const reporting = createReporting({
21
+ sources: {
22
+ members: async () => users.repositories.users.find(),
23
+ units: async () => gym.repositories.units.find(),
24
+ events: async ({ from, to }) => (await gym.repositories.events.find())
25
+ .filter(from && to ? inRange('at', { from, to }) : () => true),
26
+ },
27
+ reports: [
28
+ defineReport({ name: 'members', source: 'members', metrics: {
29
+ total: count(),
30
+ actifs: count((m) => m.status === 'active'),
31
+ avecVisage: count((m) => Array.isArray(m.faceDescriptor) && m.faceDescriptor.length),
32
+ } }),
33
+ defineReport({ name: 'occupancy', source: 'units', metrics: {
34
+ total: count(),
35
+ occupes: count((u) => u.status === 'assigned'),
36
+ tauxOccupation: rate((u) => u.status === 'assigned'),
37
+ } }),
38
+ defineReport({ name: 'checkins', source: 'events', groupBy: 'action', metrics: { total: count() } }),
39
+ ],
40
+ });
41
+
42
+ // KPIs membres
43
+ const m = await reporting.run('members');
44
+ assert.equal(m.metrics.total, 3);
45
+ assert.equal(m.metrics.actifs, 2);
46
+ assert.equal(m.metrics.avecVisage, 1);
47
+
48
+ // Occupation
49
+ const o = await reporting.run('occupancy');
50
+ assert.equal(o.metrics.occupes, 1);
51
+ assert.equal(o.metrics.tauxOccupation, 0.5, 'taux occupation 1/2');
52
+
53
+ // Check-ins groupés par action + rapport mensuel
54
+ const ck = await reporting.run('checkins', { groupBy: 'action' });
55
+ assert.equal(ck.groups.assigned.total, 1, 'un assigned');
56
+ const monthly = await reporting.monthly('checkins');
57
+ assert.ok(monthly.period.from && monthly.period.to, 'période mensuelle calculée');
58
+
59
+ console.log('✅ reporting — KPIs OK (members %o · occupancy taux %s · checkins %o)',
60
+ m.metrics, o.metrics.tauxOccupation, ck.groups);
package/llms.txt ADDED
@@ -0,0 +1,24 @@
1
+ # @mostajs/reporting — fiche LLM
2
+
3
+ RÔLE
4
+ Moteur de reporting générique : KPIs + rapports périodiques (jour/mois/trimestre/an) + comparaison
5
+ période précédente + export. DB-agnostique : lit via SOURCES injectées. Pas d'UI, pas de stockage.
6
+ GENERALIZE de MostaGare (metrics-calculator / AnalyticsReportService). Compose @mostajs/file-export (export).
7
+
8
+ EXPORTS
9
+ createReporting({ sources, reports, now?, exporter? }) -> api
10
+ defineReport({ name, source, metrics:{clé:fn(rows)->valeur}, groupBy? })
11
+ agrégateurs: count(pred?) sum(field) avg(field) min(field) max(field) distinctCount(field) rate(numPred,denomPred?) groupBy(rows,key,compute)
12
+ périodes: periodRange(period,ref) previousRange({from,to}) startOfDay endOfDay inRange(field,{from,to})
13
+
14
+ API (createReporting)
15
+ registerSource(name, fetch({from,to,filters})->rows[]) registerReport(def)
16
+ run(name,{from,to|period,groupBy?,comparePrevious?,filters?}) -> { report, period, count, metrics, groups?, comparison? }
17
+ daily/monthly/quarterly/annual(name, opts) export(result, format) -> via exporter (@mostajs/file-export)
18
+
19
+ PIÈGES
20
+ - Sources injectées (aucune DB en dur). metrics = fns pures rows->valeur (composer les agrégateurs).
21
+ - rate() renvoie 0..1. comparePrevious calcule delta vs période précédente de même durée.
22
+ - export exige `exporter` injecté (ex. @mostajs/file-export exportDoc). Dashboards = couche app (hors module).
23
+
24
+ CONSOMMATEURS : P1 Hadhinat, P2 ATC, P3 ASSO-SEL (KPIs/rapports). Sources typiques : users, allocations, audit, data-plug.
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@mostajs/reporting",
3
+ "version": "0.1.0",
4
+ "description": "Moteur de reporting générique : KPIs, rapports périodiques (jour/mois/trimestre/an), comparaison période précédente, export (compose @mostajs/file-export). DB-agnostique (sources injectées).",
5
+ "license": "AGPL-3.0-or-later",
6
+ "author": "Dr Hamid MADANI <drmdh@msn.com>",
7
+ "type": "module",
8
+ "main": "src/index.js",
9
+ "exports": {
10
+ ".": "./src/index.js",
11
+ "./aggregators": "./src/aggregators.js",
12
+ "./periods": "./src/periods.js"
13
+ },
14
+ "keywords": [
15
+ "mostajs",
16
+ "reporting",
17
+ "kpi",
18
+ "analytics",
19
+ "dashboard"
20
+ ],
21
+ "scripts": {
22
+ "test": "node test-scripts/unit/reporting.test.mjs",
23
+ "example": "node examples/kpis/run.mjs"
24
+ }
25
+ }
@@ -0,0 +1,23 @@
1
+ // @mostajs/reporting — agrégateurs purs (fns rows[] -> valeur). Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ // GENERALIZE de MostaGare metrics-calculator ($sum/$avg/$group, errorRate/successRate).
3
+ const num = (v) => (Number.isFinite(+v) ? +v : 0);
4
+
5
+ export const count = (pred) => (rows) => (pred ? rows.filter(pred).length : rows.length);
6
+ export const sum = (field) => (rows) => rows.reduce((s, r) => s + num(typeof field === 'function' ? field(r) : r[field]), 0);
7
+ export const avg = (field) => (rows) => (rows.length ? sum(field)(rows) / rows.length : 0);
8
+ export const min = (field) => (rows) => rows.reduce((m, r) => Math.min(m, num(r[field])), Infinity);
9
+ export const max = (field) => (rows) => rows.reduce((m, r) => Math.max(m, num(r[field])), -Infinity);
10
+ export const distinctCount = (field) => (rows) => new Set(rows.map((r) => (typeof field === 'function' ? field(r) : r[field]))).size;
11
+
12
+ /** Taux = #(num) / #(denom). denom par défaut = toutes les lignes. Retourne 0..1. */
13
+ export const rate = (numPred, denomPred) => (rows) => {
14
+ const d = (denomPred ? rows.filter(denomPred) : rows).length;
15
+ return d ? rows.filter(numPred).length / d : 0;
16
+ };
17
+
18
+ /** Regroupe rows par clé (champ ou fn) puis applique `compute(rows)` à chaque groupe. */
19
+ export function groupBy(rows, key, compute) {
20
+ const groups = {};
21
+ for (const r of rows) { const k = typeof key === 'function' ? key(r) : r[key]; (groups[k] ||= []).push(r); }
22
+ return Object.fromEntries(Object.entries(groups).map(([k, rs]) => [k, compute(rs)]));
23
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // @mostajs/reporting — point d'entrée. Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ export { createReporting, defineReport } from './reporting.js';
3
+ export { count, sum, avg, min, max, distinctCount, rate, groupBy } from './aggregators.js';
4
+ export { periodRange, previousRange, startOfDay, endOfDay, inRange } from './periods.js';
package/src/periods.js ADDED
@@ -0,0 +1,28 @@
1
+ // @mostajs/reporting — helpers de périodes. Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ const d = (x) => new Date(x);
3
+ export const startOfDay = (x) => { const r = d(x); r.setHours(0, 0, 0, 0); return r; };
4
+ export const endOfDay = (x) => { const r = d(x); r.setHours(23, 59, 59, 999); return r; };
5
+
6
+ /** Range {from,to} pour une période ('day'|'month'|'quarter'|'year') autour de `ref`. */
7
+ export function periodRange(period, ref = new Date()) {
8
+ const r = d(ref);
9
+ switch (period) {
10
+ case 'day': return { from: startOfDay(r), to: endOfDay(r), period };
11
+ case 'month': return { from: new Date(r.getFullYear(), r.getMonth(), 1), to: endOfDay(new Date(r.getFullYear(), r.getMonth() + 1, 0)), period };
12
+ case 'quarter': { const q = Math.floor(r.getMonth() / 3); return { from: new Date(r.getFullYear(), q * 3, 1), to: endOfDay(new Date(r.getFullYear(), q * 3 + 3, 0)), period }; }
13
+ case 'year': return { from: new Date(r.getFullYear(), 0, 1), to: endOfDay(new Date(r.getFullYear(), 11, 31)), period };
14
+ default: throw new Error(`période inconnue: ${period} (day|month|quarter|year)`);
15
+ }
16
+ }
17
+
18
+ /** Période précédente de même durée. */
19
+ export function previousRange({ from, to }) {
20
+ const dur = d(to).getTime() - d(from).getTime();
21
+ return { from: new Date(d(from).getTime() - dur - 1), to: new Date(d(from).getTime() - 1) };
22
+ }
23
+
24
+ /** Filtre des lignes sur un champ date dans [from,to]. */
25
+ export const inRange = (field, { from, to }) => (r) => {
26
+ const t = d(r[field]).getTime();
27
+ return t >= d(from).getTime() && t <= d(to).getTime();
28
+ };
@@ -0,0 +1,73 @@
1
+ // @mostajs/reporting — moteur de rapports. Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ // EXTRACT du patron MostaGare AnalyticsReportService (rapport + période + comparaison période précédente).
3
+ import { groupBy as groupRows } from './aggregators.js';
4
+ import { periodRange, previousRange } from './periods.js';
5
+
6
+ /** Déclare un rapport : source (nom), métriques {clé: fn(rows)->valeur}, groupBy optionnel. */
7
+ export function defineReport({ name, source, metrics = {}, groupBy = null }) {
8
+ if (!name || !source) throw new Error('defineReport: name et source requis');
9
+ return { name, source, metrics, groupBy };
10
+ }
11
+
12
+ /**
13
+ * @param {object} opts
14
+ * @param {Record<string,(q)=>Promise<any[]>|any[]>} [opts.sources] source(name) -> fetch({from,to,filters}) -> rows[]
15
+ * @param {object[]|Record<string,object>} [opts.reports] rapports (defineReport)
16
+ * @param {()=>Date} [opts.now]
17
+ * @param {(format:string, doc:object)=>Promise<any>} [opts.exporter] ex. @mostajs/file-export exportDoc
18
+ */
19
+ export function createReporting({ sources = {}, reports = {}, now = () => new Date(), exporter } = {}) {
20
+ const srcMap = { ...sources };
21
+ const repMap = {};
22
+ for (const r of (Array.isArray(reports) ? reports : Object.values(reports))) repMap[r.name] = r;
23
+
24
+ const compute = (def, rows) =>
25
+ Object.fromEntries(Object.entries(def.metrics).map(([k, fn]) => [k, fn(rows)]));
26
+
27
+ const api = {
28
+ registerSource(name, fetchFn) { srcMap[name] = fetchFn; return api; },
29
+ registerReport(def) { repMap[def.name] = def; return api; },
30
+
31
+ async run(name, { from, to, period, groupBy, comparePrevious = false, filters } = {}) {
32
+ const def = repMap[name];
33
+ if (!def) throw new Error(`rapport inconnu: ${name}`);
34
+ const fetch = srcMap[def.source];
35
+ if (!fetch) throw new Error(`source inconnue: ${def.source}`);
36
+ const range = period ? periodRange(period, now()) : { from, to };
37
+ const rows = await fetch({ ...range, filters });
38
+ const result = { report: name, period: range, count: rows.length, metrics: compute(def, rows) };
39
+
40
+ const gb = groupBy || def.groupBy;
41
+ if (gb) result.groups = groupRows(rows, gb, (rs) => compute(def, rs));
42
+
43
+ if (comparePrevious) {
44
+ const prev = previousRange(range);
45
+ const prows = await fetch({ ...prev, filters });
46
+ const prevMetrics = compute(def, prows);
47
+ result.comparison = {
48
+ period: prev,
49
+ metrics: prevMetrics,
50
+ delta: Object.fromEntries(Object.keys(result.metrics).map((k) => [k, (result.metrics[k] ?? 0) - (prevMetrics[k] ?? 0)])),
51
+ };
52
+ }
53
+ return result;
54
+ },
55
+
56
+ // sucres périodiques
57
+ daily(name, o) { return api.run(name, { ...o, period: 'day' }); },
58
+ monthly(name, o) { return api.run(name, { ...o, period: 'month' }); },
59
+ quarterly(name, o) { return api.run(name, { ...o, period: 'quarter' }); },
60
+ annual(name, o) { return api.run(name, { ...o, period: 'year' }); },
61
+
62
+ /** Export via @mostajs/file-export (exporter injecté). doc = {title, items}. */
63
+ async export(result, format = 'csv') {
64
+ if (!exporter) throw new Error('export: exporter (@mostajs/file-export) requis');
65
+ const items = Object.entries(result.metrics).map(([k, v]) => ({ title: k, fields: { valeur: v } }));
66
+ return exporter(format, { title: `${result.report} — ${fmt(result.period)}`, items });
67
+ },
68
+ };
69
+ return api;
70
+ }
71
+
72
+ const iso = (x) => { const t = new Date(x).getTime(); return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : '∅'; };
73
+ const fmt = (p = {}) => `${iso(p.from)}→${iso(p.to)}`;
@@ -0,0 +1,70 @@
1
+ // @mostajs/reporting — tests unitaires (DEVRULES §5). node test-scripts/unit/reporting.test.mjs
2
+ import assert from 'node:assert/strict';
3
+ import { createReporting, defineReport, count, sum, avg, rate, distinctCount, groupBy } from '../../src/index.js';
4
+ import { periodRange, previousRange } from '../../src/periods.js';
5
+
6
+ let pass = 0; const test = async (n, fn) => { await fn(); pass++; console.log(' ✓', n); };
7
+ const rows = [
8
+ { type: 'a', amount: 10, status: 'ok', day: '2026-06-01' },
9
+ { type: 'a', amount: 20, status: 'ko', day: '2026-06-01' },
10
+ { type: 'b', amount: 30, status: 'ok', day: '2026-06-02' },
11
+ ];
12
+
13
+ await test('agrégateurs purs', () => {
14
+ assert.equal(count()(rows), 3);
15
+ assert.equal(count((r) => r.type === 'a')(rows), 2);
16
+ assert.equal(sum('amount')(rows), 60);
17
+ assert.equal(avg('amount')(rows), 20);
18
+ assert.equal(distinctCount('type')(rows), 2);
19
+ assert.equal(rate((r) => r.status === 'ok')(rows).toFixed(3), (2 / 3).toFixed(3));
20
+ });
21
+ await test('groupBy', () => {
22
+ const g = groupBy(rows, 'type', (rs) => count()(rs));
23
+ assert.equal(g.a, 2); assert.equal(g.b, 1);
24
+ });
25
+ await test('periodRange month + previousRange', () => {
26
+ const r = periodRange('month', new Date('2026-06-15T12:00:00Z'));
27
+ assert.equal(r.from.getMonth(), 5); assert.equal(r.to.getMonth(), 5);
28
+ const p = previousRange(r);
29
+ assert.ok(p.to.getTime() < r.from.getTime());
30
+ });
31
+ await test('run: métriques + count', async () => {
32
+ const rep = createReporting({
33
+ sources: { s: () => rows },
34
+ reports: [defineReport({ name: 'r', source: 's', metrics: { total: count(), ca: sum('amount') } })],
35
+ });
36
+ const out = await rep.run('r');
37
+ assert.equal(out.count, 3); assert.equal(out.metrics.total, 3); assert.equal(out.metrics.ca, 60);
38
+ });
39
+ await test('run: groupBy', async () => {
40
+ const rep = createReporting({ sources: { s: () => rows }, reports: [defineReport({ name: 'r', source: 's', groupBy: 'type', metrics: { n: count() } })] });
41
+ const out = await rep.run('r');
42
+ assert.equal(out.groups.a.n, 2);
43
+ });
44
+ await test('run: comparaison période précédente (delta)', async () => {
45
+ const data = { cur: [{ x: 1 }, { x: 1 }], prev: [{ x: 1 }] };
46
+ const rep = createReporting({
47
+ now: () => new Date('2026-06-15'),
48
+ sources: { s: ({ from }) => (new Date(from).getMonth() === 5 ? data.cur : data.prev) },
49
+ reports: [defineReport({ name: 'r', source: 's', metrics: { total: count() } })],
50
+ });
51
+ const out = await rep.run('r', { period: 'month', comparePrevious: true });
52
+ assert.equal(out.metrics.total, 2);
53
+ assert.equal(out.comparison.metrics.total, 1);
54
+ assert.equal(out.comparison.delta.total, 1);
55
+ });
56
+ await test('export: exige un exporter', async () => {
57
+ const rep = createReporting({ sources: { s: () => rows }, reports: [defineReport({ name: 'r', source: 's', metrics: { total: count() } })] });
58
+ const out = await rep.run('r');
59
+ await assert.rejects(() => rep.export(out), /exporter.*requis/);
60
+ const captured = [];
61
+ const rep2 = createReporting({ sources: { s: () => rows }, reports: [defineReport({ name: 'r', source: 's', metrics: { total: count() } })], exporter: (fmt, doc) => { captured.push({ fmt, doc }); return 'ok'; } });
62
+ await rep2.export(await rep2.run('r'), 'csv');
63
+ assert.equal(captured[0].fmt, 'csv'); assert.equal(captured[0].doc.items[0].title, 'total');
64
+ });
65
+ await test('erreurs : rapport/source inconnus', async () => {
66
+ const rep = createReporting({});
67
+ await assert.rejects(() => rep.run('nope'), /rapport inconnu/);
68
+ });
69
+
70
+ console.log(`\n✅ @mostajs/reporting — ${pass} tests OK`);