@dnax/ui 0.0.42 → 0.0.44

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,991 @@
1
+ // Tests unitaires du traducteur marks → option ECharts (bun test).
2
+ // Seule la **marque `text`** est couverte pour l'instant (Plot.text).
3
+ import { describe, expect, it } from "bun:test"
4
+ import {
5
+ MARK_OPTIONS,
6
+ chartToECharts,
7
+ chartLink,
8
+ legendPickOf,
9
+ linkedRows,
10
+ linkValue,
11
+ nextLegendPick,
12
+ pickFromEvent,
13
+ sameSelection,
14
+ tableModel,
15
+ tableModel,
16
+ textValue,
17
+ } from "./chart"
18
+ import type { QChartConfig, QChartMark } from "./chart"
19
+
20
+ /** Option produite par une marque `text` seule, avec un thème minimal */
21
+ const option = (marks: QChartMark[], rest: Partial<QChartConfig> = {}) =>
22
+ chartToECharts({
23
+ marks,
24
+ theme: { text: "#111111", muted: "#666666", grid: "#e5e5e5" },
25
+ tokens: { primary: "#1976d2" },
26
+ ...rest,
27
+ })
28
+
29
+ const labelOf = (marks: QChartMark[]) => {
30
+ const series = option(marks).series.find((s: any) => s.type === "scatter")
31
+ return { series, label: series.label }
32
+ }
33
+
34
+ const rows = [
35
+ { month: "Jan", revenue: 42, city: "Paris" },
36
+ { month: "Feb", revenue: 51, city: "Lyon" },
37
+ ]
38
+
39
+ describe("marque `text`", () => {
40
+ it("pose le libellé sur le point (scatter + label), point invisible", () => {
41
+ const { series, label } = labelOf([{ type: "text", data: rows, x: "month", y: "revenue", text: "revenue" }])
42
+ expect(series.type).toBe("scatter")
43
+ expect(series.symbolSize).toBe(1)
44
+ expect(series.itemStyle).toEqual({ color: "transparent", opacity: 1 })
45
+ expect(series.data.map((d: any) => d.label.formatter)).toEqual(["42", "51"])
46
+ // Les points portent les coordonnées attendues
47
+ expect(series.data.map((d: any) => d.value)).toEqual([
48
+ ["Jan", 42],
49
+ ["Feb", 51],
50
+ ])
51
+ expect(label.show).toBe(true)
52
+ expect(label.position).toBe("inside")
53
+ })
54
+
55
+ it("centre le texte par défaut (ancres `middle`, comme Plot)", () => {
56
+ const { label } = labelOf([{ type: "text", data: rows, x: "month", y: "revenue", text: "month" }])
57
+ expect(label.align).toBe("center")
58
+ expect(label.verticalAlign).toBe("middle")
59
+ expect(label.fontSize).toBe(11)
60
+ expect(label.offset).toBeUndefined()
61
+ expect(label.rotate).toBeUndefined()
62
+ })
63
+
64
+ it("traduit `textAnchor` / `lineAnchor`", () => {
65
+ const at = (textAnchor: any, lineAnchor: any) =>
66
+ labelOf([{ type: "text", data: rows, x: "month", y: "revenue", text: "revenue", textAnchor, lineAnchor }]).label
67
+ expect(at("start", "top")).toMatchObject({ align: "left", verticalAlign: "top" })
68
+ expect(at("middle", "bottom")).toMatchObject({ align: "center", verticalAlign: "bottom" })
69
+ // `center` est accepté comme synonyme de `middle` (Plot écrit `middle`)
70
+ expect(at("center", "middle")).toMatchObject({ align: "center", verticalAlign: "middle" })
71
+ expect(at("end", "middle")).toMatchObject({ align: "right", verticalAlign: "middle" })
72
+ })
73
+
74
+ it("décale le libellé avec `dx` / `dy`", () => {
75
+ const { label } = labelOf([
76
+ { type: "text", data: rows, x: "month", y: "revenue", text: "revenue", dx: 6, dy: -8, lineAnchor: "bottom" },
77
+ ])
78
+ expect(label.offset).toEqual([6, -8])
79
+ })
80
+
81
+ it("inverse le sens de `rotate` (Plot = horaire, label.rotate = anti-horaire)", () => {
82
+ const { label } = labelOf([{ type: "text", data: rows, x: "month", y: "revenue", text: "revenue", rotate: 90 }])
83
+ expect(label.rotate).toBe(-90)
84
+ })
85
+
86
+ it("applique la police et le retour à la ligne", () => {
87
+ const { label } = labelOf([
88
+ {
89
+ type: "text",
90
+ data: rows,
91
+ x: "month",
92
+ y: "revenue",
93
+ text: "theme",
94
+ fontSize: 12,
95
+ fontWeight: "bold",
96
+ fontFamily: "Urbanist",
97
+ fontStyle: "italic",
98
+ lineHeight: 18,
99
+ lineWidth: 10,
100
+ },
101
+ ])
102
+ expect(label).toMatchObject({
103
+ fontSize: 12,
104
+ fontWeight: "bold",
105
+ fontFamily: "Urbanist",
106
+ fontStyle: "italic",
107
+ lineHeight: 18,
108
+ width: 120, // 10 ems × 12 px
109
+ overflow: "break",
110
+ })
111
+ })
112
+
113
+ it("tronque avec `textOverflow`", () => {
114
+ const ellipsis = labelOf([
115
+ { type: "text", data: rows, x: "month", y: "revenue", text: "city", lineWidth: 4, textOverflow: "ellipsis" },
116
+ ]).label
117
+ expect(ellipsis).toMatchObject({ overflow: "truncate", ellipsis: "…", width: 44 })
118
+ const clip = labelOf([
119
+ { type: "text", data: rows, x: "month", y: "revenue", text: "city", lineWidth: 4, textOverflow: "clip" },
120
+ ]).label
121
+ expect(clip).toMatchObject({ overflow: "truncate", ellipsis: "" })
122
+ })
123
+
124
+ it("colore le texte (`fill`) et dessine un halo (`fill` + `stroke`)", () => {
125
+ const text = labelOf([{ type: "text", data: rows, x: "month", y: "revenue", text: "revenue", fill: "primary" }]).label
126
+ expect(text.color).toBe("#1976d2")
127
+ expect(text.textBorderColor).toBeUndefined()
128
+
129
+ // `stroke` seul : colore le texte (comme un `dot`), pas de halo
130
+ const lone = labelOf([{ type: "text", data: rows, x: "month", y: "revenue", text: "revenue", stroke: "#c10015" }]).label
131
+ expect(lone.color).toBe("#c10015")
132
+ expect(lone.textBorderColor).toBeUndefined()
133
+
134
+ // `fill` + `stroke` : halo, épaisseur 3 par défaut (Plot) puis `strokeWidth`
135
+ const halo = labelOf([
136
+ { type: "text", data: rows, x: "month", y: "revenue", text: "revenue", fill: "#fff", stroke: "#1d1d1d" },
137
+ ]).label
138
+ expect(halo).toMatchObject({ color: "#fff", textBorderColor: "#1d1d1d", textBorderWidth: 3 })
139
+ const thick = labelOf([
140
+ {
141
+ type: "text",
142
+ data: rows,
143
+ x: "month",
144
+ y: "revenue",
145
+ text: "revenue",
146
+ fill: "#fff",
147
+ stroke: "#1d1d1d",
148
+ strokeWidth: 5,
149
+ },
150
+ ]).label
151
+ expect(thick.textBorderWidth).toBe(5)
152
+ })
153
+
154
+ it("résout par point les canaux qui varient (text, fontSize, dx, rotate, fill)", () => {
155
+ const mark: QChartMark = {
156
+ type: "text",
157
+ data: rows,
158
+ x: "month",
159
+ y: "revenue",
160
+ text: "city",
161
+ fontSize: 10,
162
+ dx: 0,
163
+ rotate: 0,
164
+ fill: "primary",
165
+ }
166
+ // Canaux explicites (un tableau de valeurs par ligne)
167
+ const { series } = labelOf([
168
+ {
169
+ ...mark,
170
+ text: ["A", "B"],
171
+ fontSize: [10, 14],
172
+ dx: [0, 4],
173
+ rotate: [0, 30],
174
+ fill: ["primary", "#c10015"],
175
+ },
176
+ ])
177
+ expect(series.data[0].label).toEqual({ formatter: "A" })
178
+ expect(series.data[1].label).toEqual({
179
+ formatter: "B",
180
+ fontSize: 14,
181
+ offset: [4, 0],
182
+ rotate: -30,
183
+ color: "#c10015",
184
+ })
185
+ })
186
+
187
+ it("ajoute l'info-bulle par point quand `title` est donné", () => {
188
+ const { series } = labelOf([
189
+ { type: "text", data: rows, x: "month", y: "revenue", text: "revenue", title: "city" },
190
+ ])
191
+ expect(series.data[0].tooltip).toEqual({ formatter: "Paris" })
192
+ expect(series.data[1].tooltip).toEqual({ formatter: "Lyon" })
193
+ })
194
+
195
+ it("garde `type: 'text'` dans les types de marque et la légende via `z`", () => {
196
+ const marks: QChartMark[] = [{ type: "text", data: rows, x: "month", y: "revenue", text: "revenue", z: "city" }]
197
+ const chart = option(marks)
198
+ // Une série (et une entrée de légende) par valeur du canal `z`
199
+ expect(chart.series.map((s: any) => s.name)).toEqual(["Paris", "Lyon"])
200
+ expect(chart.series.map((s: any) => s.data.length)).toEqual([1, 1])
201
+ expect(chart.legend.show).not.toBe(false)
202
+ expect(chart.tooltip.trigger).toBe("item")
203
+ })
204
+
205
+ it("accepte les données en paires (raccourci Plot, sans `x` ni `y`)", () => {
206
+ const chart = option([{ type: "text", data: [[1, 4], [2, 7]], text: (d: any) => `${d[1]} %` }])
207
+ const series = chart.series[0]
208
+ expect(series.data.map((d: any) => d.value)).toEqual([
209
+ [1, 4],
210
+ [2, 7],
211
+ ])
212
+ expect(series.data.map((d: any) => d.label.formatter)).toEqual(["4 %", "7 %"])
213
+ expect(chart.xAxis.type).toBe("value")
214
+ })
215
+ })
216
+
217
+ describe("marque `pie`", () => {
218
+ const share = [
219
+ { channel: "Online", revenue: 62 },
220
+ { channel: "Store", revenue: 41 },
221
+ { channel: "Partners", revenue: 27 },
222
+ ]
223
+
224
+ it("produit une série `pie` — une part par ligne — **sans axes ni grid**", () => {
225
+ const chart = option([{ type: "pie", data: share, x: "channel", y: "revenue" }])
226
+ expect(chart.series.length).toBe(1)
227
+ expect(chart.series[0].type).toBe("pie")
228
+ expect(chart.series[0].data).toEqual([
229
+ { name: "Online", value: 62 },
230
+ { name: "Store", value: 41 },
231
+ { name: "Partners", value: 27 },
232
+ ])
233
+ // Famille hors axes : rien de cartésien dans l'option
234
+ expect(chart.xAxis).toBeUndefined()
235
+ expect(chart.yAxis).toBeUndefined()
236
+ expect(chart.grid).toBeUndefined()
237
+ // Une part se survole à l'unité (tooltip `item`)
238
+ expect(chart.tooltip.trigger).toBe("item")
239
+ })
240
+
241
+ it("rayon : `radius` (défaut 70 %) et `innerRadius` → anneau", () => {
242
+ const pie = option([{ type: "pie", data: share, x: "channel", y: "revenue" }]).series[0]
243
+ expect(pie.radius).toBe("70%")
244
+ const donut = option([
245
+ { type: "pie", data: share, x: "channel", y: "revenue", radius: "80%", innerRadius: "50%" },
246
+ ]).series[0]
247
+ expect(donut.radius).toEqual(["50%", "80%"])
248
+ const px = option([{ type: "pie", data: share, x: "channel", y: "revenue", radius: 90 }]).series[0]
249
+ expect(px.radius).toBe(90)
250
+ })
251
+
252
+ it("libellés : `name` par défaut, puis `value`/`percent`/`name-value`/`name-percent`/`false`", () => {
253
+ const label = (labels: any) =>
254
+ option([{ type: "pie", data: share, x: "channel", y: "revenue", labels }]).series[0].label
255
+ expect(label(undefined).formatter).toBe("{b}")
256
+ expect(label("value").formatter).toBe("{c}")
257
+ expect(label("percent").formatter).toBe("{d}%")
258
+ expect(label("name-value").formatter).toBe("{b}: {c}")
259
+ expect(label("name-percent").formatter).toBe("{b} {d}%")
260
+ expect(label(false)).toEqual({ show: false })
261
+ })
262
+
263
+ it("`startAngle`, `fill` par part, `opacity` et `title`", () => {
264
+ const chart = option([
265
+ {
266
+ type: "pie",
267
+ data: share,
268
+ x: "channel",
269
+ y: "revenue",
270
+ startAngle: 180,
271
+ opacity: 0.9,
272
+ fill: ["primary", "#c10015", "chart-1"],
273
+ title: "revenue",
274
+ },
275
+ ])
276
+ const pie = chart.series[0]
277
+ expect(pie.startAngle).toBe(180)
278
+ expect(pie.data.map((d: any) => d.itemStyle)).toEqual([
279
+ { color: "#1976d2", opacity: 0.9 },
280
+ { color: "#c10015", opacity: 0.9 },
281
+ { color: "#1976d2", opacity: 0.9 }, // `chart-1` → repli Material en l'absence de token
282
+ ])
283
+ expect(pie.data[0].tooltip).toEqual({ formatter: "62" })
284
+ })
285
+
286
+ it("sans `fill`, laisse ECharts colorier les parts avec la palette", () => {
287
+ const pie = option([{ type: "pie", data: share, x: "channel", y: "revenue" }]).series[0]
288
+ expect(pie.data.every((d: any) => d.itemStyle === undefined)).toBe(true)
289
+ expect(option([{ type: "pie", data: share, x: "channel", y: "revenue" }]).color[0]).toBeTruthy()
290
+ })
291
+
292
+ it("n'ajoute pas de catégories d'axe (les noms de parts ne sont pas des catégories)", () => {
293
+ const chart = option([{ type: "pie", data: share, x: "channel", y: "revenue" }])
294
+ expect(chart.xAxis).toBeUndefined()
295
+ // Un graphique mixte (camembert + barres) garde ses axes
296
+ const mixed = option([
297
+ { type: "pie", data: share, x: "channel", y: "revenue" },
298
+ { type: "bar", data: share, x: "channel", y: "revenue" },
299
+ ])
300
+ expect(mixed.xAxis.type).toBe("category")
301
+ expect(mixed.xAxis.data).toEqual(["Online", "Store", "Partners"])
302
+ })
303
+
304
+ it("la légende liste les parts dès que la marque est nommée", () => {
305
+ const chart = option([{ type: "pie", data: share, x: "channel", y: "revenue", name: "Revenue" }])
306
+ expect(chart.series[0].name).toBe("Revenue")
307
+ expect(chart.legend.show).not.toBe(false)
308
+ })
309
+ })
310
+
311
+ describe("marque `heatmap`", () => {
312
+ const grid = [
313
+ { day: "Mon", hour: "9h", temp: 12 },
314
+ { day: "Mon", hour: "12h", temp: 21 },
315
+ { day: "Tue", hour: "9h", temp: 8 },
316
+ { day: "Tue", hour: "12h", temp: 18 },
317
+ ]
318
+ const heat = (marks: QChartMark[]) => option(marks)
319
+
320
+ it("deux axes **catégories** (colonnes = `x`, lignes = `y`) et un `visualMap`", () => {
321
+ const chart = heat([{ type: "heatmap", data: grid, x: "hour", y: "day", fill: "temp" }])
322
+ expect(chart.series[0].type).toBe("heatmap")
323
+ // Les cellules portent des **index** (colonne, ligne) et la valeur
324
+ expect(chart.series[0].data).toEqual([
325
+ { name: "Mon · 9h", value: [0, 0, 12] },
326
+ { name: "Mon · 12h", value: [1, 0, 21] },
327
+ { name: "Tue · 9h", value: [0, 1, 8] },
328
+ { name: "Tue · 12h", value: [1, 1, 18] },
329
+ ])
330
+ expect(chart.xAxis).toMatchObject({ type: "category", data: ["9h", "12h"], boundaryGap: true })
331
+ expect(chart.yAxis).toMatchObject({ type: "category", data: ["Mon", "Tue"] })
332
+ expect(chart.xAxis.splitArea).toEqual({ show: true })
333
+ // ECharts exige un visualMap pour une série heatmap : la palette sert de rampe
334
+ expect(chart.visualMap.length).toBe(1)
335
+ expect(chart.visualMap[0]).toMatchObject({ min: 8, max: 21, show: true })
336
+ expect(chart.visualMap[0].inRange.color.length).toBe(6)
337
+ // Il faut de la place sous l'axe pour l'échelle de couleurs
338
+ expect(chart.grid.bottom).toBe(8 + 30)
339
+ expect(chart.tooltip.trigger).toBe("item")
340
+ })
341
+
342
+ it("couleur **constante** → toutes les cellules de cette couleur, échelle cachée", () => {
343
+ const chart = heat([{ type: "heatmap", data: grid, x: "hour", y: "day", fill: "primary" }])
344
+ expect(chart.visualMap[0].show).toBe(false)
345
+ expect(chart.visualMap[0].inRange.color).toEqual(["#1976d2", "#1976d2"])
346
+ expect(chart.grid.bottom).toBe(8)
347
+ })
348
+
349
+ it("sans `fill`, la première couleur de la palette (échelle cachée)", () => {
350
+ const chart = heat([{ type: "heatmap", data: grid, x: "hour", y: "day" }])
351
+ const first = chart.color[0]
352
+ expect(chart.visualMap[0].show).toBe(false)
353
+ expect(chart.visualMap[0].inRange.color).toEqual([first, first])
354
+ })
355
+
356
+ it("`labels: true` imprime la valeur, `title` alimente l'info-bulle, `opacity` s'applique", () => {
357
+ const chart = heat([
358
+ {
359
+ type: "heatmap",
360
+ data: grid,
361
+ x: "hour",
362
+ y: "day",
363
+ fill: "temp",
364
+ labels: true,
365
+ title: "day",
366
+ opacity: 0.8,
367
+ name: "Temperature",
368
+ },
369
+ ])
370
+ expect(chart.series[0].label).toMatchObject({ show: true, formatter: "{@[2]}" })
371
+ expect(chart.series[0].itemStyle).toEqual({ opacity: 0.8 })
372
+ expect(chart.series[0].data[0].tooltip).toEqual({ formatter: "Mon" })
373
+ expect(chart.legend.show).not.toBe(false)
374
+ const noLabels = heat([{ type: "heatmap", data: grid, x: "hour", y: "day", fill: "temp" }])
375
+ expect(noLabels.series[0].label).toBeUndefined()
376
+ })
377
+
378
+ it("les lignes et colonnes suivent l'ordre d'apparition des données", () => {
379
+ const chart = heat([
380
+ { type: "heatmap", data: [{ h: "15h", d: "Wed", v: 1 }, { h: "9h", d: "Mon", v: 2 }], x: "h", y: "d", fill: "v" },
381
+ ])
382
+ expect(chart.xAxis.data).toEqual(["15h", "9h"])
383
+ expect(chart.yAxis.data).toEqual(["Wed", "Mon"])
384
+ expect(chart.series[0].data.map((c: any) => c.value)).toEqual([
385
+ [0, 0, 1],
386
+ [1, 1, 2],
387
+ ])
388
+ })
389
+ })
390
+
391
+ describe("légende et grille", () => {
392
+ it("légende : `position`, `offset` et `align` — la place est **réservée** dans le grid", () => {
393
+ const marks: QChartMark[] = [
394
+ { type: "bar", data: rows, x: "month", y: "revenue", fill: "primary", name: "Revenue" },
395
+ ]
396
+ // Défaut : en haut à gauche, marge 24 px
397
+ const byDefault = option(marks)
398
+ expect(byDefault.legend).toMatchObject({ top: 0, left: 0 })
399
+ expect(byDefault.legend.show).toBeUndefined()
400
+ expect(byDefault.grid.top).toBe(14 + 24)
401
+ // Une marge plus grande décale la zone de tracé
402
+ const wide = option(marks, { legend: { offset: 40 } })
403
+ expect(wide.grid.top).toBe(14 + 40)
404
+ // En bas, aligné au centre
405
+ const bottom = option(marks, { legend: { position: "bottom", align: "center", offset: 16 } })
406
+ expect(bottom.legend).toMatchObject({ bottom: 0, left: "center" })
407
+ expect(bottom.grid.bottom).toBe(8 + 14 + 16)
408
+ // À gauche / à droite : légende verticale, colonne réservée
409
+ const left = option(marks, { legend: { position: "left", offset: 20 } })
410
+ expect(left.legend).toMatchObject({ orient: "vertical", left: 0, top: "middle" })
411
+ expect(left.grid.left).toBe(8 + 90 + 20)
412
+ const right = option(marks, { legend: { position: "right" } })
413
+ expect(right.legend).toMatchObject({ orient: "vertical", right: 0, top: "middle" })
414
+ expect(right.grid.right).toBe(16 + 90 + 24)
415
+ // Toujours masquable
416
+ expect(option(marks, { legend: false }).legend).toEqual({ show: false })
417
+ expect(option(marks, { legend: true }).legend.show).toBeUndefined()
418
+ })
419
+
420
+ it("titre + légende : le titre garde sa place, la légende passe dessous", () => {
421
+ const chart = option([{ type: "line", data: rows, x: "month", y: "revenue", stroke: "primary", name: "Revenue" }], {
422
+ title: "Revenue",
423
+ })
424
+ expect(chart.legend.top).toBe(26)
425
+ expect(chart.grid.top).toBe(26 + 14 + 24)
426
+ })
427
+ })
428
+
429
+ describe("marque `image`", () => {
430
+ const team = [
431
+ { name: "Amina", src: "https://cdn.test/amina.jpg", x: 1, y: 2 },
432
+ { name: "Dan", src: "https://cdn.test/dan.jpg", x: 3, y: 1 },
433
+ ]
434
+
435
+ it("sans `round` : symbole `image://` (constante au niveau de la série, canal par point)", () => {
436
+ const perRow = option([{ type: "image", data: team, x: "x", y: "y", src: "src", r: 18 }]).series[0]
437
+ expect(perRow.symbol).toBeUndefined()
438
+ expect(perRow.symbolKeepAspect).toBe(true)
439
+ expect(perRow.data.map((d: any) => d.symbol)).toEqual([
440
+ "image://https://cdn.test/amina.jpg",
441
+ "image://https://cdn.test/dan.jpg",
442
+ ])
443
+ const constant = option([{ type: "image", data: team, x: "x", y: "y", src: "/logo.svg", r: 10 }]).series[0]
444
+ expect(constant.symbol).toBe("image:///logo.svg")
445
+ expect(constant.data.every((d: any) => d.symbol === undefined)).toBe(true)
446
+ })
447
+
448
+ it("`round: true` : symbole `circle` rempli par un **motif** image, par point", () => {
449
+ const chart = option([{ type: "image", data: team, x: "x", y: "y", src: "src", r: 22, round: true }])
450
+ const series = chart.series[0]
451
+ expect(series.symbol).toBe("circle")
452
+ expect(series.symbolKeepAspect).toBeUndefined()
453
+ // 22 → 2r = 44 px de côté
454
+ expect(series.symbolSize).toEqual([44, 44])
455
+ expect(series.data.map((d: any) => d.itemStyle)).toEqual([
456
+ { color: { image: "https://cdn.test/amina.jpg", repeat: "no-repeat" } },
457
+ { color: { image: "https://cdn.test/dan.jpg", repeat: "no-repeat" } },
458
+ ])
459
+ // `opacity` par défaut à 1 (les images ne doivent pas être délavées)
460
+ expect(series.itemStyle.opacity).toBe(1)
461
+ })
462
+
463
+ it("`round: true` + `stroke` : un anneau autour de la pastille", () => {
464
+ const chart = option([
465
+ { type: "image", data: team, x: "x", y: "y", src: "src", r: 20, round: true, stroke: "primary" },
466
+ ])
467
+ expect(chart.series[0].itemStyle).toMatchObject({ borderColor: "#1976d2", borderWidth: 2 })
468
+ const thick = option([
469
+ {
470
+ type: "image",
471
+ data: team,
472
+ x: "x",
473
+ y: "y",
474
+ src: "src",
475
+ r: 20,
476
+ round: true,
477
+ stroke: "#fff",
478
+ strokeWidth: 4,
479
+ },
480
+ ])
481
+ expect(thick.series[0].itemStyle).toMatchObject({ borderColor: "#fff", borderWidth: 4 })
482
+ })
483
+
484
+ it("`rotate` et `width`/`height` restent appliqués", () => {
485
+ const chart = option([
486
+ { type: "image", data: team, x: "x", y: "y", src: "src", width: 30, height: 20, rotate: 30 },
487
+ ])
488
+ expect(chart.series[0].symbolSize).toEqual([30, 20])
489
+ expect(chart.series[0].symbolRotate).toBe(30)
490
+ })
491
+ })
492
+
493
+ describe("marque `table`", () => {
494
+ const rows = [
495
+ { month: "Jan", revenue: 42 },
496
+ { month: "Feb", revenue: 51 },
497
+ ]
498
+ const mark = { type: "table", data: rows, link: "month" } as any
499
+
500
+ it("`filter` (défaut) ne garde que les lignes liées", () => {
501
+ expect(tableModel(mark, { name: "Feb" }).rows).toEqual([rows[1]])
502
+ })
503
+
504
+ it("`dim` garde **toutes** les lignes : c'est le rendu qui estompe les autres", () => {
505
+ const t = tableModel(mark, { name: "Feb" }, "dim")
506
+ expect(t.rows.length).toBe(2)
507
+ expect(t.rows[1]).toBe(rows[1]) // mêmes références, aucun clone
508
+ })
509
+
510
+ it("sans sélection, `dim` et `filter` rendent la même chose", () => {
511
+ expect(tableModel(mark, null, "dim").rows).toEqual(tableModel(mark, null).rows)
512
+ })
513
+
514
+ it("les colonnes sont déduites des lignes quand elles ne sont pas déclarées", () => {
515
+ expect(tableModel(mark, null).columns.map((c) => c.field)).toEqual(["month", "revenue"])
516
+ })
517
+
518
+ it("les séparateurs : `horizontal` par défaut, puis `vertical`, `grid`, `none`", () => {
519
+ expect(tableModel(mark, null).separator).toBe("horizontal")
520
+ for (const separator of ["vertical", "grid", "none"] as const) {
521
+ expect(tableModel({ ...mark, separator }, null).separator).toBe(separator)
522
+ }
523
+ })
524
+
525
+ it("`separator` est bien une option de la marque `table` (table d'API générée)", () => {
526
+ expect(MARK_OPTIONS.table).toEqual(["data", "link", "columns", "separator"])
527
+ })
528
+ })
529
+
530
+ describe("tooltip par défaut", () => {
531
+ const monthly = [{ month: "Jan", revenue: 42, cost: 28 }]
532
+ const opt = (marks: any[], cfg: any = {}) =>
533
+ option(marks, { x: { type: "band" }, ...cfg } as any)
534
+
535
+ it("`axis` pour bar **et** line, avec un pointeur explicite", () => {
536
+ const barChart = opt([{ type: "bar", data: monthly, x: "month", y: "revenue" }])
537
+ expect(barChart.tooltip.trigger).toBe("axis")
538
+ // une barre : bandeau (`shadow`) sous la catégorie survolée
539
+ expect(barChart.tooltip.axisPointer).toEqual({ type: "shadow" })
540
+
541
+ const lineChart = opt([{ type: "line", data: monthly, x: "month", y: "revenue" }])
542
+ expect(lineChart.tooltip.trigger).toBe("axis")
543
+ // un trait : curseur vertical
544
+ expect(lineChart.tooltip.axisPointer).toEqual({ type: "line" })
545
+ })
546
+
547
+ it("`axis` aussi pour des points seuls (graphique cartésien sans `title`)", () => {
548
+ const chart = opt([{ type: "dot", data: monthly, x: "month", y: "revenue" }])
549
+ expect(chart.series[0].type).toBe("scatter")
550
+ expect(chart.tooltip.trigger).toBe("axis")
551
+ expect(chart.tooltip.axisPointer).toEqual({ type: "line" })
552
+ })
553
+
554
+ it("reste en `item` quand l'info est par point ou cellule", () => {
555
+ // un `title` par point ne s'afficherait jamais dans un tooltip d'axe
556
+ expect(opt([{ type: "dot", data: monthly, x: "month", y: "revenue", title: "revenue" }]).tooltip.trigger)
557
+ .toBe("item")
558
+ // graphique d'étiquettes : la donnée est le libellé
559
+ expect(opt([{ type: "text", data: monthly, x: "month", y: "revenue", text: "revenue" }]).tooltip.trigger)
560
+ .toBe("item")
561
+ })
562
+
563
+ it("`tooltip: false` éteint tout, pointeur compris", () => {
564
+ const chart = opt([{ type: "bar", data: monthly, x: "month", y: "revenue" }], { tooltip: false })
565
+ expect(chart.tooltip).toEqual({ show: false })
566
+ })
567
+ })
568
+
569
+ describe("lisibilité des axes", () => {
570
+ const monthly = [{ month: "Jan", revenue: 42 }]
571
+
572
+ it("les nombres de l'axe des ordonnées respirent (marge > défaut 8)", () => {
573
+ const chart = option([{ type: "bar", data: monthly, x: "month", y: "revenue" }], { x: { type: "band" } })
574
+ expect(chart.yAxis.axisLabel.margin).toBe(16)
575
+ // la place est réservée automatiquement par le moteur
576
+ expect(chart.grid.containLabel).toBe(true)
577
+ })
578
+
579
+ it("`margin` est réglable par axe", () => {
580
+ const chart = option([{ type: "bar", data: monthly, x: "month", y: "revenue" }], {
581
+ x: { type: "band" },
582
+ y: { margin: 4 },
583
+ })
584
+ expect(chart.yAxis.axisLabel.margin).toBe(4)
585
+ expect(chart.yAxis.axisLabel.color).toBe("#666666") // le reste du style est conservé
586
+ })
587
+ })
588
+
589
+ describe("mode `dim` : estomper au lieu de retirer", () => {
590
+ const rows = [
591
+ { month: "Jan", revenue: 42, cost: 28 },
592
+ { month: "Feb", revenue: 51, cost: 31 },
593
+ ]
594
+ const bar = (rest: any) =>
595
+ option([{ type: "bar", data: rows, x: "month", y: "revenue", link: "month" }], {
596
+ selection: { name: "Feb" },
597
+ x: { type: "band" },
598
+ ...rest,
599
+ })
600
+
601
+ it("garde **toutes** les lignes (le contexte reste lisible)", () => {
602
+ expect(bar({ linkMode: "dim" }).series[0].data).toHaveLength(2)
603
+ })
604
+
605
+ it("abaisse l'opacité par défaut et relève la sélection (`select`)", () => {
606
+ const series = bar({ linkMode: "dim" }).series[0]
607
+ expect(series.itemStyle).toMatchObject({ opacity: 0.25 })
608
+ expect(series.selectedMode).toBe("single")
609
+ expect(series.select).toMatchObject({ itemStyle: { opacity: 1 } })
610
+ })
611
+
612
+ it("la sélection **annule la bordure** du style `select` par défaut, sans toucher au rayon", () => {
613
+ const series = bar({ linkMode: "dim" }).series[0]
614
+ expect(series.select.itemStyle).toMatchObject({ opacity: 1, borderWidth: 0, borderColor: "transparent" })
615
+ // rayon : laissé tel qu'à l'état initial (aucune surcharge dans le style de sélection)
616
+ expect("borderRadius" in series.select.itemStyle).toBe(false)
617
+ })
618
+
619
+ it("l'état estompé ne surcharge que l'opacité (rayon et bordure de la marque intacts)", () => {
620
+ const rounded = option(
621
+ [{ type: "bar", data: rows, x: "month", y: "revenue", link: "month" }],
622
+ { selection: { name: "Feb" }, linkMode: "dim", x: { type: "band" } },
623
+ ).series[0]
624
+ // le rayon des barres vient du traducteur (`[3, 3, 0, 0]`) : il doit survivre à l'estompage
625
+ expect(rounded.itemStyle.borderRadius).toEqual([3, 3, 0, 0])
626
+ expect(rounded.itemStyle.opacity).toBe(0.25)
627
+ expect("borderWidth" in rounded.itemStyle).toBe(false)
628
+ })
629
+
630
+ it("`dimOpacity` est configurable", () => {
631
+ expect(bar({ linkMode: "dim", dimOpacity: 0.4 }).series[0].itemStyle).toMatchObject({ opacity: 0.4 })
632
+ })
633
+
634
+ it("une ligne est estompée **avec son trait**", () => {
635
+ const chart = option([{ type: "line", data: rows, x: "month", y: "cost", link: "month" }], {
636
+ selection: { name: "Feb" },
637
+ linkMode: "dim",
638
+ x: { type: "band" },
639
+ })
640
+ expect(chart.series[0].lineStyle).toMatchObject({ opacity: 0.25 })
641
+ expect(chart.series[0].select.lineStyle).toMatchObject({ opacity: 1 })
642
+ })
643
+
644
+ it("**sans sélection**, rien n'est estompé : tout est à plein contraste", () => {
645
+ const rest = option([{ type: "bar", data: rows, x: "month", y: "revenue", link: "month" }], {
646
+ linkMode: "dim",
647
+ x: { type: "band" },
648
+ })
649
+ expect(rest.series[0].itemStyle?.opacity).toBeUndefined()
650
+ expect(rest.series[0].selectedMode).toBeUndefined()
651
+ expect(rest.series[0].data).toHaveLength(2)
652
+ // une sélection vide ne compte pas comme une sélection
653
+ const blank = option([{ type: "bar", data: rows, x: "month", y: "revenue", link: "month" }], {
654
+ linkMode: "dim",
655
+ selection: {},
656
+ x: { type: "band" },
657
+ })
658
+ expect(blank.series[0].itemStyle?.opacity).toBeUndefined()
659
+ })
660
+
661
+ it("le mode `filter` reste le défaut : les lignes partent, aucun style d'estompage", () => {
662
+ const series = bar({}).series[0]
663
+ expect(series.data).toHaveLength(1)
664
+ expect(series.selectedMode).toBeUndefined()
665
+ expect(series.select).toBeUndefined()
666
+ expect(series.itemStyle?.opacity).toBeUndefined()
667
+ })
668
+ })
669
+
670
+ describe("marque `table` (`tableModel`)", () => {
671
+ const rows = [
672
+ { month: "Jan", revenue: 42, cost: 28 },
673
+ { month: "Feb", revenue: 51, cost: 31 },
674
+ ]
675
+
676
+ it("déduit les colonnes des clés, dans l'ordre d'apparition", () => {
677
+ const model = tableModel({ type: "table", data: rows })
678
+ expect(model.columns.map((c) => c.field)).toEqual(["month", "revenue", "cost"])
679
+ expect(model.columns.map((c) => c.label)).toEqual(["month", "revenue", "cost"])
680
+ expect(model.rows).toHaveLength(2)
681
+ })
682
+
683
+ it("respecte les colonnes déclarées (champ, en-tête, alignement, format)", () => {
684
+ const model = tableModel({
685
+ type: "table",
686
+ data: rows,
687
+ columns: [
688
+ { field: "month", label: "Month" },
689
+ { field: "revenue", label: "Revenue", align: "end", format: (v) => `${v} €` },
690
+ ],
691
+ })
692
+ expect(model.columns).toEqual([
693
+ { field: "month", label: "Month" },
694
+ { field: "revenue", label: "Revenue", align: "end", format: expect.any(Function) },
695
+ ])
696
+ expect(model.columns[1].format?.(42, rows[0])).toBe("42 €")
697
+ })
698
+
699
+ it("accepte les colonnes en abrégé (`'month'`)", () => {
700
+ expect(tableModel({ type: "table", data: rows, columns: ["month"] }).columns).toEqual([
701
+ { field: "month", label: "month" },
702
+ ])
703
+ })
704
+
705
+ it("les lignes suivent la **jointure** (`link`) comme n'importe quelle marque", () => {
706
+ const mark: QChartMark = { type: "table", data: rows, link: "month" }
707
+ expect(tableModel(mark, { name: "Feb" }).rows).toEqual([{ month: "Feb", revenue: 51, cost: 31 }])
708
+ expect(tableModel(mark, null).rows).toHaveLength(2)
709
+ // les colonnes restent celles des lignes visibles
710
+ expect(tableModel(mark, { name: "Jan" }).columns.map((c) => c.field)).toEqual(["month", "revenue", "cost"])
711
+ })
712
+
713
+ it("marque absente ou données vides → modèle vide (pas d'erreur)", () => {
714
+ expect(tableModel(undefined)).toEqual({ columns: [], rows: [], separator: "horizontal" })
715
+ expect(tableModel({ type: "table" })).toEqual({ columns: [], rows: [], separator: "horizontal" })
716
+ })
717
+
718
+ it("le traducteur ignore une marque `table` (aucune série produite)", () => {
719
+ const chart = option([
720
+ { type: "table", data: rows, columns: ["month"] },
721
+ { type: "bar", data: rows, x: "month", y: "revenue" },
722
+ ])
723
+ expect(chart.series).toHaveLength(1)
724
+ expect(chart.series[0].type).toBe("bar")
725
+ })
726
+ })
727
+
728
+ describe("re-clic = désélection (`sameSelection`)", () => {
729
+ it("compare d'abord le `name` — la clé partagée entre graphiques", () => {
730
+ expect(sameSelection({ name: "Feb" }, { name: "Feb", markIndex: 3, dataIndex: 9 })).toBe(true)
731
+ expect(sameSelection({ name: "Feb" }, { name: "Mar" })).toBe(false)
732
+ // le type compte peu : un même mois dans deux marques, c'est la même sélection
733
+ expect(sameSelection({ name: "Feb", markType: "bar" }, { name: "Feb", markType: "line" })).toBe(true)
734
+ })
735
+
736
+ it("à défaut de nom, compare la position (marque + donnée)", () => {
737
+ expect(sameSelection({ markIndex: 0, dataIndex: 2 }, { name: undefined, markIndex: 0, dataIndex: 2 })).toBe(true)
738
+ expect(sameSelection({ markIndex: 0, dataIndex: 2 }, { markIndex: 0, dataIndex: 3 })).toBe(false)
739
+ expect(sameSelection({ markIndex: 1, dataIndex: 2 }, { markIndex: 0, dataIndex: 2 })).toBe(false)
740
+ })
741
+
742
+ it("sans sélection courante (ou position incomplète), rien n'est « déjà sélectionné »", () => {
743
+ expect(sameSelection(null, { name: "Feb" })).toBe(false)
744
+ expect(sameSelection(undefined, { name: "Feb" })).toBe(false)
745
+ expect(sameSelection({ markIndex: 0 }, { markIndex: 0 })).toBe(false)
746
+ expect(sameSelection({ dataIndex: 2 }, { dataIndex: 2 })).toBe(false)
747
+ })
748
+
749
+ it("un nom face à une sélection sans nom ne conclut pas à l'égalité", () => {
750
+ expect(sameSelection({ markIndex: 0, dataIndex: 1 }, { name: "Feb" })).toBe(false)
751
+ })
752
+ })
753
+
754
+ describe("identité d'un clic de légende (`legendPickOf`)", () => {
755
+ const monthly = [
756
+ { month: "Jan", revenue: 42, cost: 28 },
757
+ { month: "Feb", revenue: 51, cost: 31 },
758
+ { month: "Mar", revenue: 47, cost: 30 },
759
+ ]
760
+
761
+ it("reconstruit la marque ET la donnée depuis le seul nom", () => {
762
+ const pick = legendPickOf([{ type: "pie", data: monthly, x: "month", y: "revenue", name: "Revenue" }], "Jan")
763
+ expect(pick).toEqual({
764
+ name: "Jan",
765
+ origin: "legend",
766
+ markIndex: 0,
767
+ markName: "Revenue",
768
+ markType: "pie",
769
+ dataIndex: 0,
770
+ data: { month: "Jan", revenue: 42, cost: 28 },
771
+ value: 42,
772
+ })
773
+ })
774
+
775
+ it("cherche dans toutes les marques et donne l'index exact de la donnée", () => {
776
+ const marks: QChartMark[] = [
777
+ { type: "bar", data: monthly, x: "month", y: "revenue", name: "Revenue" },
778
+ { type: "line", data: monthly, x: "month", y: "cost", name: "Cost" },
779
+ ]
780
+ expect(legendPickOf(marks, "Mar")).toMatchObject({ markIndex: 0, markType: "bar", dataIndex: 2, value: 47 })
781
+ expect(legendPickOf(marks, "Feb")).toMatchObject({ markIndex: 0, dataIndex: 1, markName: "Revenue" })
782
+ })
783
+
784
+ it("`markHint` (la marque cliquée) passe en premier — deux marques peuvent partager un nom", () => {
785
+ const marks: QChartMark[] = [
786
+ { type: "bar", data: monthly, x: "month", y: "revenue", name: "Revenue" },
787
+ { type: "line", data: monthly, x: "month", y: "cost", name: "Cost" },
788
+ ]
789
+ // sans indice : la première marque qui porte le nom
790
+ expect(legendPickOf(marks, "Feb")).toMatchObject({ markIndex: 0, markType: "bar", dataIndex: 1 })
791
+ // avec l'indice du clic : la marque visée, et sa valeur d'ordonnée
792
+ expect(legendPickOf(marks, "Feb", 1)).toMatchObject({ markIndex: 1, markType: "line", dataIndex: 1, value: 31 })
793
+ // indice hors bornes → recherche normale
794
+ expect(legendPickOf(marks, "Feb", 7)).toMatchObject({ markIndex: 0, markType: "bar" })
795
+ })
796
+
797
+ it("nom inconnu → sélection réduite au nom (rien d'inventé)", () => {
798
+ expect(legendPickOf([{ type: "bar", data: monthly, x: "month", y: "revenue" }], "Nope")).toEqual({
799
+ name: "Nope",
800
+ origin: "legend",
801
+ })
802
+ expect(legendPickOf(undefined, "Jan")).toEqual({ name: "Jan", origin: "legend" })
803
+ })
804
+ })
805
+
806
+ describe("clic de légende en mode `select` (`nextLegendPick`)", () => {
807
+ it("sélectionne le nom cliqué", () => {
808
+ const pick = nextLegendPick(null, "Jan", { month: "Jan" })
809
+ expect(pick).toEqual({ name: "Jan", origin: "legend", data: { month: "Jan" } })
810
+ })
811
+
812
+ it("bascule sur un autre nom", () => {
813
+ expect(nextLegendPick({ name: "Jan" }, "Feb")).toEqual({ name: "Feb", origin: "legend", data: undefined })
814
+ })
815
+
816
+ it("**désélectionne** quand on reclique le nom courant (`null`)", () => {
817
+ expect(nextLegendPick({ name: "Jan" }, "Jan")).toBeNull()
818
+ // la comparaison est textuelle : 42 et « 42 » désignent la même part
819
+ expect(nextLegendPick({ name: 42 }, "42")).toBeNull()
820
+ })
821
+
822
+ it("une sélection sans nom (ou absente) laisse sélectionner", () => {
823
+ expect(nextLegendPick({}, "Jan")).toMatchObject({ name: "Jan" })
824
+ expect(nextLegendPick(undefined, "Jan")).toMatchObject({ name: "Jan" })
825
+ expect(nextLegendPick({ name: undefined }, "Jan")).toMatchObject({ name: "Jan" })
826
+ })
827
+ })
828
+
829
+ describe("jointure d'une marque à la sélection (`link`)", () => {
830
+ const rows = [
831
+ { month: "Jan", revenue: 42, cost: 28 },
832
+ { month: "Feb", revenue: 51, cost: 31 },
833
+ { month: "Mar", revenue: 47, cost: 30 },
834
+ ]
835
+
836
+ it("`link: 'month'` = `{ localField: 'month' }`", () => {
837
+ expect(chartLink("month")).toEqual({ localField: "month" })
838
+ expect(chartLink({ localField: "m", foreignField: "month" })).toEqual({ localField: "m", foreignField: "month" })
839
+ expect(chartLink(undefined)).toBeUndefined()
840
+ })
841
+
842
+ it("résout la clé : donnée du pick → champ du pick → son `name` → scalaire", () => {
843
+ const link = { localField: "month" }
844
+ expect(linkValue(link, { name: "Jan", data: { month: "Mar" } })).toBe("Mar")
845
+ expect(linkValue({ localField: "m", foreignField: "month" }, { data: { month: "Feb" } })).toBe("Feb")
846
+ expect(linkValue({ localField: "m", foreignField: "month" }, { month: "Apr" })).toBe("Apr")
847
+ expect(linkValue(link, { name: "Jan" })).toBe("Jan")
848
+ expect(linkValue(link, "Jan")).toBe("Jan")
849
+ expect(linkValue(link, null)).toBeUndefined()
850
+ })
851
+
852
+ it("filtre les lignes jointes, sans sélection garde tout", () => {
853
+ expect(linkedRows(rows, { localField: "month" }, { name: "Feb" }).map((r: any) => r.month)).toEqual(["Feb"])
854
+ expect(linkedRows(rows, { localField: "month" }, null)).toHaveLength(3)
855
+ expect(linkedRows(rows, undefined, { name: "Feb" })).toHaveLength(3)
856
+ })
857
+
858
+ it("garde-fou : champ absent des lignes → la marque n'est pas vidée", () => {
859
+ expect(linkedRows(rows, { localField: "semaine" }, { name: "Feb" })).toHaveLength(3)
860
+ expect(linkedRows(rows, { localField: "month" }, { name: "Dec" })).toHaveLength(0)
861
+ })
862
+
863
+ /** Valeurs d'une série cartésienne, quel que soit l'encodage (nombre ou paire `[x, y]`) */
864
+ const values = (series: any) =>
865
+ series.data.map((item: any) => (Array.isArray(item?.value) ? item.value[1] : (item?.value ?? item)))
866
+
867
+ it("le traducteur filtre la marque liée, et laisse les autres intactes", () => {
868
+ const marks: QChartMark[] = [
869
+ { type: "bar", data: rows, x: "month", y: "revenue", link: "month" },
870
+ { type: "line", data: rows, x: "month", y: "cost" },
871
+ ]
872
+ const chart = option(marks, { selection: { name: "Feb" }, x: { type: "band" } })
873
+ expect(values(chart.series[0])).toEqual([51]) // la barre liée : février seulement
874
+ expect(values(chart.series[1])).toEqual([28, 31, 30]) // la ligne non liée : tout
875
+ // L'axe est **commun** : ses catégories restent l'union des marques (une marque liée
876
+ // seule ne rétrécit donc pas l'axe — il faut que toutes les marques soient liées).
877
+ expect(chart.xAxis.data).toEqual(["Feb", "Jan", "Mar"])
878
+ })
879
+
880
+ it("toutes les marques liées → l'axe se réduit à la clé (cross-filter complet)", () => {
881
+ const marks: QChartMark[] = [
882
+ { type: "bar", data: rows, x: "month", y: "revenue", link: "month" },
883
+ { type: "line", data: rows, x: "month", y: "cost", link: "month" },
884
+ ]
885
+ const chart = option(marks, { selection: { name: "Jan" }, x: { type: "band" } })
886
+ expect(values(chart.series[0])).toEqual([42])
887
+ expect(values(chart.series[1])).toEqual([28])
888
+ expect(chart.xAxis.data).toEqual(["Jan"])
889
+ })
890
+
891
+ it("jointure croisée : `foreignField` écrit autrement que le champ local", () => {
892
+ const channels = [
893
+ { label: "Online", revenue: 120 },
894
+ { label: "Store", revenue: 80 },
895
+ ]
896
+ const driven = option(
897
+ [{ type: "bar", data: channels, x: "label", y: "revenue", link: { localField: "label", foreignField: "month" } }],
898
+ { selection: { name: "x", data: { month: "Online" } }, x: { type: "band" } },
899
+ )
900
+ expect(values(driven.series[0])).toEqual([120])
901
+ })
902
+ })
903
+
904
+ describe("démo « Interaction » — les 4 marques portent une légende", () => {
905
+ // Marks exacts de `DnaxDemoChart.vue` (demo="interaction" : bar, pie, line, dot)
906
+ const monthly = [
907
+ { month: "Jan", revenue: 42, cost: 28 },
908
+ { month: "Feb", revenue: 51, cost: 31 },
909
+ { month: "Mar", revenue: 47, cost: 30 },
910
+ ]
911
+ const axes = { x: { type: "band" as const }, y: { min: 0 } }
912
+
913
+ // Une légende allumée n'a ni `show` ni `data` : ECharts prend `show: true` par défaut et
914
+ // déduit les entrées des noms de séries (cartésien) ou des noms de données (pie). Éteinte,
915
+ // le traducteur pose explicitement `{ show: false, data: [] }`.
916
+ const legendOn = (chart: any) => chart.legend.show !== false
917
+
918
+ it("bar / line / dot : la légende s'allume dès que la série est nommée", () => {
919
+ const bar = option([{ type: "bar", data: monthly, x: "month", y: "revenue", fill: "chart-1", name: "Revenue" }], axes)
920
+ const line = option([{ type: "line", data: monthly, x: "month", y: "cost", stroke: "primary", name: "Cost" }], axes)
921
+ const dot = option([{ type: "dot", data: monthly, x: "month", y: "revenue", fill: "chart-2", r: 5, name: "Revenue" }], axes)
922
+ for (const [label, chart] of [["bar", bar], ["line", line], ["dot", dot]] as const) {
923
+ expect(legendOn(chart), label).toBe(true)
924
+ expect(chart.series.some((s: any) => !!s.name), label).toBe(true)
925
+ // ni `show` ni `data` : ECharts reprend les noms de séries
926
+ expect(chart.legend.data, label).toBeUndefined()
927
+ }
928
+ // une série sans nom → pas de légende (et l'espace n'est pas réservé dans le grid)
929
+ const unnamed = option([{ type: "bar", data: monthly, x: "month", y: "revenue" }], axes)
930
+ expect(legendOn(unnamed)).toBe(false)
931
+ })
932
+
933
+ it("pie : la légende liste les parts, à droite et verticale", () => {
934
+ const pie = option(
935
+ [{ type: "pie", data: monthly, x: "month", y: "revenue", labels: "percent", name: "Revenue" }],
936
+ { legend: { position: "right" } },
937
+ )
938
+ expect(legendOn(pie)).toBe(true)
939
+ expect(pie.legend).toMatchObject({ orient: "vertical", right: 0, top: "middle" })
940
+ expect(pie.legend.data).toBeUndefined() // ECharts liste les noms de données (Jan, Feb…)
941
+ })
942
+ })
943
+
944
+ describe("pickFromEvent", () => {
945
+ it("normalise l'événement de clic en vocabulaire de **marque**", () => {
946
+ expect(
947
+ pickFromEvent({
948
+ componentType: "series",
949
+ seriesType: "bar",
950
+ seriesIndex: 0,
951
+ seriesName: "Revenue",
952
+ name: "Feb",
953
+ value: 51,
954
+ dataIndex: 2,
955
+ data: { month: "Feb", revenue: 51 },
956
+ // propriétés non retenues (event DOM, couleur, etc.)
957
+ event: { type: "click" },
958
+ color: "#1976d2",
959
+ }),
960
+ ).toEqual({
961
+ origin: "mark",
962
+ markType: "bar",
963
+ markIndex: 0,
964
+ markName: "Revenue",
965
+ name: "Feb",
966
+ value: 51,
967
+ dataIndex: 2,
968
+ data: { month: "Feb", revenue: 51 },
969
+ })
970
+ })
971
+
972
+ it("omet les clés absentes ou nulles (payload comparable)", () => {
973
+ expect(pickFromEvent({ componentType: "series", name: undefined, value: null })).toEqual({ origin: "mark" })
974
+ // une entrée de légende n'est pas une marque
975
+ expect(pickFromEvent({ componentType: "legend", name: "Jan" })).toEqual({ origin: "legend", name: "Jan" })
976
+ // un composant inconnu (axe…) ne produit pas d'`origin`
977
+ expect(pickFromEvent({ componentType: "xAxis", name: "Jan" })).toEqual({ name: "Jan" })
978
+ expect(Object.keys(pickFromEvent({}))).toEqual([])
979
+ expect(pickFromEvent(undefined)).toEqual({})
980
+ })
981
+ })
982
+
983
+ describe("textValue", () => {
984
+ it("suit Plot : canal `text`, sinon la valeur de la ligne, sinon son index", () => {
985
+ const data = [{ a: 1 }, { a: 2 }]
986
+ expect(textValue({ type: "text", text: "a" }, data, 1)).toBe(2)
987
+ expect(textValue({ type: "text", text: ["x", "y"] }, data, 0)).toBe("x")
988
+ expect(textValue({ type: "text" }, data, 1)).toBe(1) // objets → index
989
+ expect(textValue({ type: "text" }, [10, 20, 30], 2)).toBe(30) // primitives → la valeur
990
+ })
991
+ })