@dnax/ui 0.0.67 → 0.0.70

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.
@@ -0,0 +1,839 @@
1
+ // Tests des extensions **BI / PKI** de `<q-chart>` (P0 + P1) :
2
+ // formats Intl, empilement 100 %, barres flottantes, axe secondaire, étiquettes, bandes de
3
+ // seuil, seuils de couleur, table triable/paginée, mode compact, info-bulle personnalisée,
4
+ // et les marques hors axes (jauge, entonnoir, radar, treemap, sunburst, sankey, boxplot,
5
+ // histogramme, cascade).
6
+ //
7
+ // Aucun navigateur : les options sont inspectées directement, et chaque nouvelle famille est
8
+ // rendue en **SVG SSR** (la technique validée dans `chart.axis.test.ts`).
9
+ import { describe, expect, it } from "bun:test"
10
+ import * as echarts from "echarts"
11
+ import { MARK_OPTIONS, chartToECharts, tableCsv, tableModel } from "./chart"
12
+ import type { QChartConfig, QChartMark } from "./chart"
13
+ import { formatValue, timeTickFormatter } from "./chartFormat"
14
+ import { boxStats, histogram, linearFit, percentShares, waterfallSteps } from "./chartStats"
15
+
16
+ const THEME = { text: "#111111", muted: "#666666", grid: "#e5e5e5" }
17
+ const option = (marks: QChartMark[], rest: Partial<QChartConfig> = {}) =>
18
+ chartToECharts({
19
+ marks,
20
+ theme: THEME,
21
+ tokens: { primary: "#1976d2" },
22
+ ...rest,
23
+ })
24
+
25
+ /** Intl utilise des espaces insécables (fines) : les tests comparent des espaces normaux */
26
+ const plain = (s: string) => s.replace(/[\u00a0\u202f]/g, " ")
27
+
28
+ const monthly = [
29
+ { month: "Jan", revenue: 42, cost: 28 },
30
+ { month: "Feb", revenue: 51, cost: 31 },
31
+ { month: "Mar", revenue: 47, cost: 30 },
32
+ ]
33
+ const bar = (rest: Partial<QChartMark> = {}): QChartMark => ({
34
+ type: "bar",
35
+ data: monthly,
36
+ x: "month",
37
+ y: "revenue",
38
+ ...rest,
39
+ })
40
+
41
+ /** Rend une option en SVG via le SSR d'ECharts (contrôle de bout en bout, sans navigateur). */
42
+ const renderSvg = (config: QChartConfig) => {
43
+ const chart = echarts.init(null as any, null, { renderer: "svg", ssr: true, width: 480, height: 300 })
44
+ chart.setOption(chartToECharts({ theme: THEME, tokens: { primary: "#1976d2" }, ...config }))
45
+ const svg = chart.renderToSVGString()
46
+ chart.dispose()
47
+ return svg
48
+ }
49
+
50
+ describe("formatage des valeurs (Intl)", () => {
51
+ it("n'applique **aucun** format sans demande explicite", () => {
52
+ expect(formatValue(42)).toBe("42")
53
+ expect(formatValue("Jan")).toBe("Jan")
54
+ expect(formatValue(null, "compact")).toBe("")
55
+ expect(formatValue(undefined, "percent")).toBe("")
56
+ })
57
+
58
+ it("nombres, compact, pourcentage et devise (fr-FR)", () => {
59
+ const fr = { locale: "fr-FR" }
60
+ expect(plain(formatValue(1234.5, "number", fr))).toBe("1 234,5")
61
+ expect(plain(formatValue(1_234_567, "compact", fr))).toBe("1,2 M")
62
+ // `percent` attend une valeur **déjà en pourcentage** : 42 → 42 %
63
+ expect(plain(formatValue(42, "percent", fr))).toBe("42 %")
64
+ expect(formatValue(42, "percent", { locale: "en-US" })).toBe("42%")
65
+ expect(plain(formatValue(1234, "currency", fr))).toContain("F CFA")
66
+ expect(plain(formatValue(12, { style: "number", suffix: " €" }, fr))).toBe("12 €")
67
+ })
68
+
69
+ it("dates : Date, timestamp (s et ms), chaîne ISO, et fuseau horaire", () => {
70
+ expect(formatValue("2024-03-05", { style: "date", locale: "fr-FR" })).toBe("5 mars 2024")
71
+ expect(formatValue(new Date("2024-03-05T00:00:00Z"), { style: "date", locale: "fr-FR" })).toBe(
72
+ "5 mars 2024",
73
+ )
74
+ // timestamp en secondes (API usuelles) et en millisecondes
75
+ expect(formatValue(1_709_596_800, { style: "date", locale: "fr-FR" })).toBe("5 mars 2024")
76
+ expect(formatValue(1_709_596_800_000, { style: "date", locale: "fr-FR" })).toBe("5 mars 2024")
77
+ expect(formatValue("2024-03-05T10:30:00Z", { style: "time", locale: "fr-FR", timeZone: "UTC" })).toBe("10:30")
78
+ })
79
+
80
+ it("accepte une fonction, et les propriétés non numériques telles quelles", () => {
81
+ expect(formatValue(3, (v) => `#${v}`)).toBe("#3")
82
+ expect(formatValue("N/A", "compact")).toBe("N/A")
83
+ })
84
+
85
+ it("granularité d'un axe temporel", () => {
86
+ const ts = Date.parse("2024-03-05T00:00:00Z")
87
+ expect(timeTickFormatter("auto")).toBeUndefined()
88
+ expect(timeTickFormatter("year", "fr-FR")!(ts)).toBe("2024")
89
+ expect(timeTickFormatter("quarter", "fr-FR")!(ts)).toBe("T1 24")
90
+ const month = timeTickFormatter("month", "fr-FR")!(ts)
91
+ expect(month).toBe(new Intl.DateTimeFormat("fr-FR", { month: "short", year: "2-digit" }).format(ts))
92
+ })
93
+ })
94
+
95
+ describe("statistiques (histogram, boîte, tendance, cascade)", () => {
96
+ it("histogramme : tranches propres, somme des effectifs = valeurs", () => {
97
+ const values = [1, 2, 2, 3, 5, 8, 8, 13, 21, 34]
98
+ const bins = histogram(values, { bins: 5 })
99
+ expect(bins.length).toBe(5)
100
+ expect(bins.reduce((s, b) => s + b.count, 0)).toBe(values.length)
101
+ expect(bins[0]!.x0).toBeLessThanOrEqual(1)
102
+ expect(bins[bins.length - 1]!.x1).toBeGreaterThanOrEqual(34)
103
+ // une seule valeur → une tranche
104
+ expect(histogram([7]).length).toBe(1)
105
+ // domaine forcé
106
+ const forced = histogram([1, 2, 3], { bins: 2, domain: [0, 10] })
107
+ expect(forced[0]!.x0).toBe(0)
108
+ expect(forced[forced.length - 1]!.x1).toBe(10)
109
+ expect(histogram([])).toEqual([])
110
+ })
111
+
112
+ it("cinq nombres et quantiles (R-7)", () => {
113
+ expect(boxStats([1, 2, 3, 4, 5])).toEqual({ min: 1, q1: 2, median: 3, q3: 4, max: 5 })
114
+ expect(boxStats([])).toBeUndefined()
115
+ })
116
+
117
+ it("régression linéaire", () => {
118
+ expect(linearFit([[0, 1], [1, 3], [2, 5]])).toEqual({ slope: 2, intercept: 1 })
119
+ expect(linearFit([[1, 1], [1, 2]])).toBeUndefined()
120
+ expect(linearFit([[1, 1]])).toBeUndefined()
121
+ })
122
+
123
+ it("cascade : cumul et lignes de total", () => {
124
+ const steps = waterfallSteps([10, -3, 5, 0], [false, false, false, true])
125
+ expect(steps.map((s) => [s.base, s.delta, s.total, s.totalRow])).toEqual([
126
+ [0, 10, 10, false],
127
+ [10, -3, 7, false],
128
+ [7, 5, 12, false],
129
+ [0, 12, 12, true],
130
+ ])
131
+ })
132
+
133
+ it("parts en pourcentage", () => {
134
+ expect(percentShares([25, 75])).toEqual([25, 75])
135
+ expect(percentShares([0, 0])).toEqual([0, 0])
136
+ })
137
+ })
138
+
139
+ describe("axe secondaire (`yRight`)", () => {
140
+ it("second axe Y et `yAxisIndex` sur la marque visée", () => {
141
+ const chart = option(
142
+ [
143
+ { type: "bar", data: monthly, x: "month", y: "cost", name: "Cost" },
144
+ { type: "line", data: monthly, x: "month", y: "revenue", yAxis: "right", name: "Revenue" },
145
+ ],
146
+ { yRight: { label: "Revenue" } },
147
+ )
148
+ expect(Array.isArray(chart.yAxis)).toBe(true)
149
+ expect(chart.yAxis.length).toBe(2)
150
+ expect(chart.yAxis[0].min).toBe(0) // barres → axe gauche à zéro
151
+ expect(chart.yAxis[1].name).toBe("Revenue")
152
+ expect(chart.series[0].yAxisIndex).toBeUndefined()
153
+ expect(chart.series[1].yAxisIndex).toBe(1)
154
+ // la place de l'axe de droite est réservée
155
+ expect(chart.grid.right).toBeGreaterThan(16)
156
+ })
157
+
158
+ it("sans marque à droite : un seul axe (objet, pas un tableau)", () => {
159
+ const chart = option([bar()])
160
+ expect(Array.isArray(chart.yAxis)).toBe(false)
161
+ })
162
+ })
163
+
164
+ describe("empilement 100 % (`stack: 'percent'`)", () => {
165
+ it("normalise chaque catégorie et partage le groupe d'empilement", () => {
166
+ const rows = [
167
+ { month: "Jan", a: 30, b: 90 },
168
+ { month: "Feb", a: 10, b: 30 },
169
+ ]
170
+ const chart = option(
171
+ [
172
+ { type: "bar", data: rows, x: "month", y: "a", stack: "percent", name: "A" },
173
+ { type: "bar", data: rows, x: "month", y: "b", stack: "percent", name: "B" },
174
+ ],
175
+ { x: { type: "band" } },
176
+ )
177
+ expect(chart.series[0].stack).toBe("total")
178
+ expect(chart.series[1].stack).toBe("total")
179
+ expect(chart.series[0].data.map((d: any) => d.value)).toEqual([
180
+ ["Jan", 25],
181
+ ["Feb", 25],
182
+ ])
183
+ expect(chart.series[1].data.map((d: any) => d.value)).toEqual([
184
+ ["Jan", 75],
185
+ ["Feb", 75],
186
+ ])
187
+ })
188
+
189
+ it("un groupe nommé n'est pas normalisé, `percent` l'est", () => {
190
+ const rows = [{ month: "Jan", a: 1, b: 1 }]
191
+ const chart = option(
192
+ [
193
+ { type: "bar", data: rows, x: "month", y: "a", stack: "volume" },
194
+ { type: "bar", data: rows, x: "month", y: "b", stack: "volume" },
195
+ ],
196
+ { x: { type: "band" } },
197
+ )
198
+ expect(chart.series[0].data[0].value).toEqual(["Jan", 1])
199
+ expect(chart.series[0].stack).toBe("volume")
200
+ })
201
+ })
202
+
203
+ describe("barres flottantes (`base`)", () => {
204
+ const rows = [{ month: "Jan", low: 10, high: 25 }]
205
+ const chart = option([{ type: "bar", data: rows, x: "month", y: "high", base: "low" }], {
206
+ x: { type: "band" },
207
+ })
208
+
209
+ it("deux séries empilées : base invisible + segment", () => {
210
+ expect(chart.series.length).toBe(2)
211
+ expect(chart.series[0].itemStyle.color).toBe("transparent")
212
+ expect(chart.series[0].silent).toBe(true)
213
+ expect(chart.series[0].data[0].value).toEqual(["Jan", 10])
214
+ expect(chart.series[1].data[0].value).toEqual(["Jan", 15]) // 25 − 10
215
+ expect(chart.series[0].stack).toBe(chart.series[1].stack)
216
+ })
217
+
218
+ it("`baseFill` colore la base, et reste hors légende", () => {
219
+ const colored = option(
220
+ [{ type: "bar", data: rows, x: "month", y: "high", base: "low", baseFill: "primary", name: "Budget" }],
221
+ { x: { type: "band" } },
222
+ )
223
+ expect(colored.series[0].itemStyle.color).toBe("#1976d2")
224
+ expect(colored.series[0].name).toBeUndefined()
225
+ })
226
+ })
227
+
228
+ describe("étiquettes de données (`labels`)", () => {
229
+ it("affiche la valeur (formatée si besoin), à la position demandée", () => {
230
+ const chart = option([bar({ labels: true, labelPosition: "bottom" })])
231
+ expect(chart.series[0].label).toMatchObject({ show: true, position: "bottom", formatter: "{c}" })
232
+ const formatted = option([bar({ labels: true, labelFormat: "compact" })], { locale: "fr-FR" })
233
+ const formatter = formatted.series[0].label.formatter as (p: any) => string
234
+ expect(plain(formatter({ value: ["Jan", 1_200_000] }))).toBe("1,2 M")
235
+ })
236
+
237
+ it("`percent` calcule la part de la catégorie dans le graphique", () => {
238
+ const rows = [
239
+ { month: "Jan", a: 30, b: 10 },
240
+ { month: "Feb", a: 20, b: 20 },
241
+ ]
242
+ const chart = option(
243
+ [
244
+ { type: "bar", data: rows, x: "month", y: "a", name: "A", labels: "percent", labelFormat: "percent" },
245
+ { type: "bar", data: rows, x: "month", y: "b", name: "B" },
246
+ ],
247
+ { x: { type: "band" } },
248
+ )
249
+ const formatter = chart.series[0].label.formatter as (p: any) => string
250
+ expect(plain(formatter({ value: ["Jan", 30] }))).toBe("75 %")
251
+ })
252
+
253
+ it("`labels: 'name'` affiche la catégorie", () => {
254
+ expect(option([bar({ labels: "name" })]).series[0].label.formatter).toBe("{b}")
255
+ })
256
+
257
+ it("les étiquettes sont une option de `bar`, `line` et `area`", () => {
258
+ for (const type of ["bar", "line", "area"] as const) {
259
+ expect(MARK_OPTIONS[type]).toContain("labels")
260
+ }
261
+ })
262
+ })
263
+
264
+ describe("bandes de seuil (`band`) et repères annotés", () => {
265
+ it("`band` → markArea (y0 → y1), couleur et libellé", () => {
266
+ const chart = option([
267
+ bar(),
268
+ { type: "band", y0: 40, y1: 60, fill: "warning", label: "Zone cible", labelPosition: "insideBottom" },
269
+ ])
270
+ const area = chart.series[0].markArea.data[0]
271
+ expect(area[1]).toEqual({ yAxis: 60 })
272
+ expect(area[0].yAxis).toBe(40)
273
+ expect(area[0].itemStyle.color).toBe("#f2c037")
274
+ expect(area[0].label).toMatchObject({ show: true, formatter: "Zone cible", position: "insideBottom" })
275
+ })
276
+
277
+ it("`band` verticale (`x0` / `x1`)", () => {
278
+ const chart = option([bar(), { type: "band", x0: "Jan", x1: "Feb", fill: "primary" }])
279
+ expect(chart.series[0].markArea.data[0][0].xAxis).toBe("Jan")
280
+ expect(chart.series[0].markArea.data[0][1].xAxis).toBe("Feb")
281
+ })
282
+
283
+ it("`rule` accepte un libellé", () => {
284
+ const chart = option([bar(), { type: "rule", y: [50], label: "Objectif" }])
285
+ expect(chart.series[0].markLine.data[0].label).toMatchObject({ show: true, formatter: "Objectif" })
286
+ })
287
+ })
288
+
289
+ describe("seuils de couleur (`thresholds`)", () => {
290
+ const rows = [
291
+ { month: "Jan", v: 5 },
292
+ { month: "Feb", v: 15 },
293
+ ]
294
+ it("colore le point selon la première règle qui matche", () => {
295
+ const chart = option([
296
+ { type: "bar", data: rows, x: "month", y: "v", thresholds: [{ value: 10, color: "negative" }] },
297
+ ])
298
+ expect(chart.series[0].data[0].itemStyle).toBeUndefined()
299
+ expect(chart.series[0].data[1].itemStyle.color).toBe("#c10015")
300
+ })
301
+
302
+ it("respecte l'opérateur (`lte`, `eq`, `neq`)", () => {
303
+ const low = option([
304
+ { type: "dot", data: rows, x: "month", y: "v", thresholds: [{ value: 10, op: "lte", color: "positive" }] },
305
+ ])
306
+ expect(low.series[0].data[0].itemStyle.color).toBe("#21ba45")
307
+ const neq = option([
308
+ { type: "dot", data: rows, x: "month", y: "v", thresholds: [{ value: 10, op: "neq", color: "primary" }] },
309
+ ])
310
+ expect(neq.series[0].data.every((d: any) => d.itemStyle?.color === "#1976d2")).toBe(true)
311
+ })
312
+ })
313
+
314
+ describe("données manquantes et tendance", () => {
315
+ it("un `null` coupe la ligne, `connectNulls` la relie", () => {
316
+ const data = [
317
+ { month: "Jan", v: 1 },
318
+ { month: "Feb", v: null },
319
+ { month: "Mar", v: 3 },
320
+ ]
321
+ const chart = option([{ type: "line", data, x: "month", y: "v", connectNulls: true }])
322
+ expect(chart.series[0].connectNulls).toBe(true)
323
+ expect(chart.series[0].data.map((d: any) => d.value)).toEqual([
324
+ ["Jan", 1],
325
+ ["Feb", null],
326
+ ["Mar", 3],
327
+ ])
328
+ })
329
+
330
+ it("`trend` ajoute une droite de régression (deux points, hors légende)", () => {
331
+ const chart = option([
332
+ {
333
+ type: "dot",
334
+ data: [
335
+ { x: 1, y: 2 },
336
+ { x: 2, y: 4 },
337
+ { x: 3, y: 6 },
338
+ ],
339
+ x: "x",
340
+ y: "y",
341
+ trend: true,
342
+ },
343
+ ])
344
+ const trend = chart.series.find((s: any) => s.__trend)
345
+ expect(trend).toBeDefined()
346
+ expect(trend.data.length).toBe(2)
347
+ expect(trend.data[0].value).toEqual([1, 2])
348
+ expect(trend.data[1].value).toEqual([3, 6])
349
+ })
350
+
351
+ it("`fill` numérique d'un `dot` → échelle de couleurs dédiée", () => {
352
+ const chart = option([
353
+ {
354
+ type: "dot",
355
+ data: [
356
+ { x: 1, y: 2, v: 10 },
357
+ { x: 2, y: 3, v: 30 },
358
+ ],
359
+ x: "x",
360
+ y: "y",
361
+ fill: "v",
362
+ },
363
+ ])
364
+ expect(chart.visualMap.length).toBe(1)
365
+ expect(chart.visualMap[0]).toMatchObject({ seriesIndex: 0, dimension: 2, min: 10, max: 30, show: false })
366
+ // la valeur de couleur voyage en 3ᵉ dimension du point
367
+ expect(chart.series[0].data[0].value).toEqual([1, 2, 10])
368
+ })
369
+ })
370
+
371
+ describe("format des axes et axe temporel", () => {
372
+ it("`y.format` devient `axisLabel.formatter`", () => {
373
+ const chart = option([bar()], { y: { format: "compact" }, locale: "fr-FR" })
374
+ const formatter = chart.yAxis.axisLabel.formatter as (v: number) => string
375
+ expect(plain(formatter(1_200_000))).toBe("1,2 M")
376
+ })
377
+
378
+ it("rotation, intervalle et nombre de graduations", () => {
379
+ const chart = option([bar()], {
380
+ x: { type: "band", labelRotate: -35, interval: 0 },
381
+ y: { splitNumber: 4 },
382
+ })
383
+ expect(chart.xAxis.axisLabel.rotate).toBe(-35)
384
+ expect(chart.xAxis.axisLabel.interval).toBe(0)
385
+ expect(chart.yAxis.splitNumber).toBe(4)
386
+ })
387
+
388
+ it("axe temporel : type `time` et libellés par granularité", () => {
389
+ const data = [
390
+ { day: "2024-01-01", v: 1 },
391
+ { day: "2024-02-01", v: 2 },
392
+ ]
393
+ const chart = option([{ type: "line", data, x: "day", y: "v" }], {
394
+ x: { type: "time", timeUnit: "month" },
395
+ })
396
+ expect(chart.xAxis.type).toBe("time")
397
+ const formatter = chart.xAxis.axisLabel.formatter as (v: number) => string
398
+ expect(formatter(Date.parse("2024-01-01"))).toBe(
399
+ new Intl.DateTimeFormat("fr-FR", { month: "short", year: "2-digit" }).format(Date.parse("2024-01-01")),
400
+ )
401
+ })
402
+ })
403
+
404
+ describe("info-bulle personnalisable", () => {
405
+ it("`valueFormatter` construit un formateur d'axe", () => {
406
+ const chart = option([bar({ name: "Revenue" })], { tooltip: { valueFormatter: (v) => `${v} €` } })
407
+ expect(typeof chart.tooltip.formatter).toBe("function")
408
+ const html = chart.tooltip.formatter([
409
+ { seriesName: "Revenue", value: ["Jan", 42], marker: "●", axisValueLabel: "Jan" },
410
+ ])
411
+ expect(html).toContain("Revenue: 42 €")
412
+ })
413
+
414
+ it("`sortDesc` trie les séries, `formatter` global gagne", () => {
415
+ const chart = option([bar({ name: "Revenue" })], {
416
+ tooltip: {
417
+ sortDesc: true,
418
+ valueFormatter: (v) => String(v),
419
+ formatter: (params: any) => `custom:${params[0].seriesName}`,
420
+ },
421
+ })
422
+ expect(typeof chart.tooltip.formatter).toBe("function")
423
+ expect(chart.tooltip.formatter([{ seriesName: "Revenue" }])).toBe("custom:Revenue")
424
+ const sorted = option([bar({ name: "Revenue" })], { tooltip: { sortDesc: true } })
425
+ const html = sorted.tooltip.formatter([
426
+ { seriesName: "A", value: ["Jan", 1], axisValueLabel: "Jan" },
427
+ { seriesName: "B", value: ["Jan", 9], axisValueLabel: "Jan" },
428
+ ])
429
+ expect(html.indexOf("B: 9")).toBeLessThan(html.indexOf("A: 1"))
430
+ })
431
+
432
+ it("un formateur **par marque** reçoit la valeur brute", () => {
433
+ const chart = option([
434
+ bar({ tooltip: (_p: any, value: any) => `prix ${value}` }),
435
+ ])
436
+ const html = chart.series[0].tooltip.formatter({ value: ["Jan", 42] })
437
+ expect(html).toBe("prix 42")
438
+ })
439
+ })
440
+
441
+ describe("mode compact (mini-widget)", () => {
442
+ it("masque la légende, les graduations et réduit le cadre", () => {
443
+ const chart = option([bar({ name: "Revenue" })], { compact: true, title: "Mini", x: { label: "Month" } })
444
+ expect(chart.legend).toEqual({ show: false })
445
+ expect(chart.xAxis.axisLabel.show).toBe(false)
446
+ expect(chart.yAxis.axisLabel.show).toBe(false)
447
+ expect(chart.xAxis.name).toBeUndefined()
448
+ expect(chart.grid).toMatchObject({ left: 2, right: 2, bottom: 2, containLabel: true })
449
+ })
450
+
451
+ it("une grille explicitement demandée reste visible", () => {
452
+ const chart = option([bar()], { compact: true, y: { grid: true } })
453
+ expect(chart.yAxis.splitLine.show).toBe(true)
454
+ })
455
+ })
456
+
457
+ describe("table BI : tri, pagination, totaux, CSV", () => {
458
+ const rows = [
459
+ { month: "Jan", revenue: 30 },
460
+ { month: "Feb", revenue: 10 },
461
+ { month: "Mar", revenue: 20 },
462
+ ]
463
+ const mark: QChartMark = { type: "table", data: rows }
464
+
465
+ it("tri croissant puis décroissant (sans muter les données)", () => {
466
+ const asc = tableModel(mark, null, "filter", { sortBy: "revenue" })
467
+ expect(asc.rows.map((r) => r.month)).toEqual(["Feb", "Mar", "Jan"])
468
+ expect(asc.sortBy).toBe("revenue")
469
+ expect(asc.sortDesc).toBe(false)
470
+ const desc = tableModel(mark, null, "filter", { sortBy: "revenue", sortDesc: true })
471
+ expect(desc.rows.map((r) => r.month)).toEqual(["Jan", "Mar", "Feb"])
472
+ // l'ordre d'origine n'est pas modifié
473
+ expect(rows.map((r) => r.month)).toEqual(["Jan", "Feb", "Mar"])
474
+ })
475
+
476
+ it("tri initial porté par la marque, pagination et pages", () => {
477
+ const paged: QChartMark = { ...mark, sortBy: "revenue", pageSize: 2 }
478
+ const first = tableModel(paged)
479
+ expect(first.rows.map((r) => r.month)).toEqual(["Feb", "Mar"])
480
+ expect(first).toMatchObject({ page: 0, pages: 2, pageSize: 2 })
481
+ expect(first.allRows?.length).toBe(3)
482
+ const second = tableModel(paged, null, "filter", { page: 1 })
483
+ expect(second.rows.map((r) => r.month)).toEqual(["Jan"])
484
+ // page hors bornes → dernière page
485
+ expect(tableModel(paged, null, "filter", { page: 9 }).page).toBe(1)
486
+ })
487
+
488
+ it("ligne de totaux (agrégats automatiques + libellé)", () => {
489
+ const withTotals: QChartMark = { ...mark, totals: { label: "Total" } }
490
+ const model = tableModel(withTotals)
491
+ expect(model.totals).toEqual({ month: "Total", revenue: "60" })
492
+ const counted: QChartMark = {
493
+ ...mark,
494
+ totals: true,
495
+ columns: [
496
+ { field: "month", label: "Mois" },
497
+ { field: "revenue", summary: "avg" },
498
+ ],
499
+ }
500
+ expect(tableModel(counted).totals?.revenue).toBe("20")
501
+ })
502
+
503
+ it("export CSV : en-têtes, échappement, toutes les lignes, totaux optionnels", () => {
504
+ const model = tableModel({ ...mark, pageSize: 1, totals: true })
505
+ const csv = tableCsv(model, { label: "Total" })
506
+ const lines = csv.split("\r\n")
507
+ expect(lines[0]).toBe("month,revenue")
508
+ expect(lines.length).toBe(1 + 3 + 1) // en-tête + toutes les lignes + total
509
+ expect(lines[lines.length - 1]).toBe("Total,60")
510
+ // guillemets et séparateurs échappés
511
+ const tricky = tableModel({ type: "table", data: [{ a: 'x,"y"', b: 1 }] })
512
+ expect(tableCsv(tricky).split("\r\n")[1]).toBe('"x,""y""",1')
513
+ })
514
+
515
+ it("les options de table sont déclarées dans MARK_OPTIONS", () => {
516
+ expect(MARK_OPTIONS.table).toEqual([
517
+ "data",
518
+ "link",
519
+ "columns",
520
+ "separator",
521
+ "sortBy",
522
+ "sortDesc",
523
+ "pageSize",
524
+ "page",
525
+ "totals",
526
+ ])
527
+ })
528
+ })
529
+
530
+ describe("marques P1 : forme de l'option", () => {
531
+ it("gauge : valeur, plages et cible", () => {
532
+ const chart = option([
533
+ {
534
+ type: "gauge",
535
+ data: [{ label: "Uptime", value: 72 }],
536
+ x: "label",
537
+ y: "value",
538
+ min: 0,
539
+ max: 100,
540
+ target: 90,
541
+ unit: "%",
542
+ bands: [
543
+ { to: 50, color: "negative" },
544
+ { to: 80, color: "warning" },
545
+ { to: 100, color: "positive" },
546
+ ],
547
+ },
548
+ ])
549
+ const gauge = chart.series[0]
550
+ expect(gauge.type).toBe("gauge")
551
+ expect(gauge.min).toBe(0)
552
+ expect(gauge.max).toBe(100)
553
+ expect(gauge.data[0]).toMatchObject({ name: "Uptime", value: 72 })
554
+ // plages converties en fractions 0→1
555
+ expect(gauge.axisLine.lineStyle.color).toEqual([
556
+ [0, "#c10015"],
557
+ [0.5, "#c10015"],
558
+ [0.8, "#f2c037"],
559
+ [1, "#21ba45"],
560
+ ])
561
+ expect(plain(gauge.detail.formatter(72))).toBe("72 %\nCible 90 %")
562
+ })
563
+
564
+ it("funnel : une part par ligne", () => {
565
+ const chart = option([
566
+ { type: "funnel", data: monthly, x: "month", y: "revenue", name: "Pipeline" },
567
+ ])
568
+ expect(chart.series[0].type).toBe("funnel")
569
+ expect(chart.series[0].data.map((d: any) => d.name)).toEqual(["Jan", "Feb", "Mar"])
570
+ })
571
+
572
+ it("radar : indicateurs déduits de `x`, une série par `z`", () => {
573
+ const rows = [
574
+ { skill: "Speed", team: "A", value: 8 },
575
+ { skill: "Power", team: "A", value: 6 },
576
+ { skill: "Speed", team: "B", value: 5 },
577
+ { skill: "Power", team: "B", value: 9 },
578
+ ]
579
+ const chart = option([{ type: "radar", data: rows, x: "skill", y: "value", z: "team" }])
580
+ expect(chart.radar.indicator.map((i: any) => i.name)).toEqual(["Speed", "Power"])
581
+ expect(chart.series[0].type).toBe("radar")
582
+ expect(chart.series[0].data.map((d: any) => [d.name, d.value])).toEqual([
583
+ ["A", [8, 6]],
584
+ ["B", [5, 9]],
585
+ ])
586
+ })
587
+
588
+ it("treemap / sunburst : hiérarchie par `parent`", () => {
589
+ const rows = [
590
+ { name: "Europe", parent: "", value: 0 },
591
+ { name: "France", parent: "Europe", value: 40 },
592
+ { name: "Spain", parent: "Europe", value: 30 },
593
+ { name: "Asia", parent: "", value: 0 },
594
+ { name: "Japan", parent: "Asia", value: 20 },
595
+ ]
596
+ const tree = option([{ type: "treemap", data: rows, x: "name", parent: "parent", value: "value" }])
597
+ expect(tree.series[0].type).toBe("treemap")
598
+ expect(tree.series[0].data.map((d: any) => d.name)).toEqual(["Europe", "Asia"])
599
+ expect(tree.series[0].data[0].children.map((c: any) => c.name)).toEqual(["France", "Spain"])
600
+ const sun = option([{ type: "sunburst", data: rows, x: "name", parent: "parent", value: "value" }])
601
+ expect(sun.series[0].type).toBe("sunburst")
602
+ expect(sun.series[0].radius).toEqual(["15%", "90%"])
603
+ })
604
+
605
+ it("sankey : nœuds déduits des liens", () => {
606
+ const chart = option([
607
+ {
608
+ type: "sankey",
609
+ data: [
610
+ { source: "Visite", target: "Panier", value: 40 },
611
+ { source: "Panier", target: "Achat", value: 12 },
612
+ ],
613
+ },
614
+ ])
615
+ expect(chart.series[0].links).toHaveLength(2)
616
+ expect(chart.series[0].data.map((d: any) => d.name)).toEqual(["Visite", "Panier", "Achat"])
617
+ })
618
+
619
+ it("boxplot : cinq nombres depuis des valeurs brutes ou précalculées", () => {
620
+ const raw = [
621
+ { team: "A", scores: [1, 2, 3, 4, 5] },
622
+ { team: "B", scores: [2, 2, 3, 3, 4] },
623
+ ]
624
+ const chart = option([{ type: "boxplot", data: raw, x: "team", values: "scores", name: "Scores" }])
625
+ expect(chart.series[0].type).toBe("boxplot")
626
+ expect(chart.series[0].data[0].value).toEqual([1, 2, 3, 4, 5])
627
+ const stats = option([
628
+ {
629
+ type: "boxplot",
630
+ data: [{ team: "A", s: [1, 2, 3, 4, 9] }],
631
+ x: "team",
632
+ stats: "s",
633
+ },
634
+ ])
635
+ expect(stats.series[0].data[0].value).toEqual([1, 2, 3, 4, 9])
636
+ })
637
+
638
+ it("histogramme : catégories = tranches, barres = effectifs", () => {
639
+ const chart = option([
640
+ { type: "histogram", data: [1, 2, 2, 3, 5, 8, 8, 13], x: (d: any) => d, bins: 4 },
641
+ ])
642
+ expect(chart.series[0].type).toBe("bar")
643
+ expect(chart.xAxis.type).toBe("category")
644
+ expect(chart.xAxis.data.length).toBe(chart.series[0].data.length)
645
+ expect(chart.series[0].data.reduce((s: number, d: any) => s + d.value, 0)).toBe(8)
646
+ })
647
+
648
+ it("heatmap : une valeur manquante laisse la case vide", () => {
649
+ const chart = option([
650
+ {
651
+ type: "heatmap",
652
+ data: [
653
+ { hour: "9h", day: "Mon", v: 1 },
654
+ { hour: "12h", day: "Mon", v: null },
655
+ ],
656
+ x: "hour",
657
+ y: "day",
658
+ fill: "v",
659
+ },
660
+ ])
661
+ expect(chart.series[0].data[1].itemStyle).toEqual({ opacity: 0 })
662
+ expect(Number.isNaN(chart.series[0].data[1].value[2])).toBe(true)
663
+ })
664
+
665
+ it("cascade : barres flottantes, totaux et connecteurs", () => {
666
+ const rows = [
667
+ { step: "Start", delta: 100 },
668
+ { step: "Cost", delta: -30 },
669
+ { step: "Total", delta: 0, total: true },
670
+ ]
671
+ const chart = option([
672
+ { type: "waterfall", data: rows, x: "step", y: "delta", isTotal: "total", name: "Waterfall" },
673
+ ])
674
+ const fall = chart.series[0]
675
+ expect(fall.type).toBe("bar")
676
+ expect(fall.data.map((d: any) => d.value)).toEqual([
677
+ [0, 100],
678
+ [70, 100],
679
+ [0, 70],
680
+ ])
681
+ // la perte est colorée avec le token `negative`
682
+ expect(fall.data[1].itemStyle.color).toBe("#c10015")
683
+ expect(fall.markLine.data.length).toBe(2)
684
+ })
685
+ })
686
+
687
+ describe("rendu SVG SSR des nouvelles familles", () => {
688
+ const configs: [string, QChartConfig][] = [
689
+ [
690
+ "yRight + band + labels",
691
+ {
692
+ marks: [
693
+ { type: "bar", data: monthly, x: "month", y: "cost", name: "Cost", labels: true },
694
+ { type: "line", data: monthly, x: "month", y: "revenue", yAxis: "right", name: "Revenue" },
695
+ { type: "band", y0: 40, y1: 60, fill: "warning" },
696
+ ],
697
+ yRight: { label: "Revenue" },
698
+ },
699
+ ],
700
+ [
701
+ "gauge",
702
+ {
703
+ marks: [
704
+ {
705
+ type: "gauge",
706
+ data: [{ label: "Uptime", value: 72 }],
707
+ x: "label",
708
+ y: "value",
709
+ target: 90,
710
+ unit: "%",
711
+ },
712
+ ],
713
+ },
714
+ ],
715
+ [
716
+ "funnel",
717
+ { marks: [{ type: "funnel", data: monthly, x: "month", y: "revenue", labels: true }] },
718
+ ],
719
+ [
720
+ "radar",
721
+ {
722
+ marks: [
723
+ {
724
+ type: "radar",
725
+ data: [
726
+ { skill: "Speed", value: 8 },
727
+ { skill: "Power", value: 6 },
728
+ { skill: "Accuracy", value: 9 },
729
+ ],
730
+ x: "skill",
731
+ y: "value",
732
+ },
733
+ ],
734
+ },
735
+ ],
736
+ [
737
+ "treemap",
738
+ {
739
+ marks: [
740
+ {
741
+ type: "treemap",
742
+ data: [
743
+ { name: "Europe", parent: "", value: 0 },
744
+ { name: "France", parent: "Europe", value: 40 },
745
+ ],
746
+ x: "name",
747
+ parent: "parent",
748
+ value: "value",
749
+ },
750
+ ],
751
+ },
752
+ ],
753
+ [
754
+ "sunburst",
755
+ {
756
+ marks: [
757
+ {
758
+ type: "sunburst",
759
+ data: [
760
+ { name: "Europe", parent: "", value: 0 },
761
+ { name: "France", parent: "Europe", value: 40 },
762
+ ],
763
+ x: "name",
764
+ parent: "parent",
765
+ value: "value",
766
+ },
767
+ ],
768
+ },
769
+ ],
770
+ [
771
+ "sankey",
772
+ {
773
+ marks: [
774
+ {
775
+ type: "sankey",
776
+ data: [
777
+ { source: "Visite", target: "Panier", value: 40 },
778
+ { source: "Panier", target: "Achat", value: 12 },
779
+ ],
780
+ },
781
+ ],
782
+ },
783
+ ],
784
+ [
785
+ "boxplot",
786
+ {
787
+ marks: [
788
+ {
789
+ type: "boxplot",
790
+ data: [
791
+ { team: "A", scores: [1, 2, 3, 4, 5] },
792
+ { team: "B", scores: [2, 2, 3, 3, 4] },
793
+ ],
794
+ x: "team",
795
+ values: "scores",
796
+ },
797
+ ],
798
+ },
799
+ ],
800
+ [
801
+ "histogram",
802
+ { marks: [{ type: "histogram", data: [1, 2, 2, 3, 5, 8, 8, 13], x: (d: any) => d, bins: 4 }] },
803
+ ],
804
+ [
805
+ "waterfall",
806
+ {
807
+ marks: [
808
+ {
809
+ type: "waterfall",
810
+ data: [
811
+ { step: "Start", delta: 100 },
812
+ { step: "Cost", delta: -30 },
813
+ { step: "Total", delta: 0, total: true },
814
+ ],
815
+ x: "step",
816
+ y: "delta",
817
+ isTotal: "total",
818
+ labels: true,
819
+ },
820
+ ],
821
+ },
822
+ ],
823
+ [
824
+ "compact",
825
+ {
826
+ compact: true,
827
+ marks: [{ type: "line", data: monthly, x: "month", y: "revenue" }],
828
+ },
829
+ ],
830
+ ]
831
+
832
+ for (const [label, config] of configs) {
833
+ it(`rend « ${label} » sans erreur`, () => {
834
+ const svg = renderSvg(config)
835
+ expect(svg.startsWith("<svg")).toBe(true)
836
+ expect(svg.length).toBeGreaterThan(200)
837
+ })
838
+ }
839
+ })