@opendata-ai/openchart-engine 7.8.0 → 7.9.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.
@@ -20,6 +20,7 @@ import type {
20
20
  } from '@opendata-ai/openchart-core';
21
21
  import type { ScaleBand, ScaleLinear } from 'd3-scale';
22
22
 
23
+ import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
23
24
  import type { NormalizedChartSpec } from '../../compiler/types';
24
25
  import type { ResolvedScales } from '../../layout/scales';
25
26
  import { getColor, getSequentialColor, groupByField } from '../utils';
@@ -77,9 +78,11 @@ export function computeDotMarks(
77
78
  const isSequentialColor = colorEnc?.type === 'quantitative';
78
79
  const colorField = isSequentialColor ? undefined : colorEnc?.field;
79
80
 
81
+ let marks: (PointMark | RectMark)[];
82
+
80
83
  // Multi-series (categorical): dumbbell chart with connecting bars
81
84
  if (colorField) {
82
- return computeDumbbellMarks(
85
+ marks = computeDumbbellMarks(
83
86
  spec.data,
84
87
  xChannel.field,
85
88
  yChannel.field,
@@ -89,20 +92,34 @@ export function computeDotMarks(
89
92
  bandwidth,
90
93
  scales,
91
94
  );
95
+ } else {
96
+ // Single series: lollipop stems from baseline
97
+ marks = computeLollipopMarks(
98
+ spec.data,
99
+ xChannel.field,
100
+ yChannel.field,
101
+ xScale,
102
+ yScale,
103
+ bandwidth,
104
+ baseline,
105
+ scales,
106
+ isSequentialColor,
107
+ );
92
108
  }
93
109
 
94
- // Single series: lollipop stems from baseline
95
- return computeLollipopMarks(
96
- spec.data,
97
- xChannel.field,
98
- yChannel.field,
99
- xScale,
100
- yScale,
101
- bandwidth,
102
- baseline,
103
- scales,
104
- isSequentialColor,
105
- );
110
+ // Stamp keys on point marks (skip rect stems/connectors)
111
+ const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
112
+ const rawKeys = pointMarks.map((m) => {
113
+ const cat = serializeKeyValue(m.data[yChannel.field]);
114
+ const series = colorField ? String(m.data[colorField] ?? '') : '';
115
+ return series ? `${series}|${cat}` : cat;
116
+ });
117
+ const keys = dedupeKeys(rawKeys);
118
+ for (let i = 0; i < pointMarks.length; i++) {
119
+ pointMarks[i].key = keys[i];
120
+ }
121
+
122
+ return marks;
106
123
  }
107
124
 
108
125
  // ---------------------------------------------------------------------------
@@ -24,6 +24,7 @@ import {
24
24
  stackOrderNone,
25
25
  } from 'd3-shape';
26
26
 
27
+ import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
27
28
  import type { NormalizedChartSpec } from '../../compiler/types';
28
29
  import type { ResolvedScales } from '../../layout/scales';
29
30
  import { getColor, scaleValue, sortByField } from '../utils';
@@ -235,8 +236,16 @@ function computeSingleArea(
235
236
  fillOpacity = 1;
236
237
  }
237
238
 
239
+ const keyField = encoding.key && 'field' in encoding.key ? encoding.key.field : undefined;
240
+ const rawPointKeys = validPoints.map((p) =>
241
+ keyField ? serializeKeyValue(p.row[keyField]) : serializeKeyValue(p.row[xChannel.field]),
242
+ );
243
+
238
244
  marks.push({
239
245
  type: 'area',
246
+ key: seriesKey === '__default__' ? 'area' : seriesKey,
247
+ pointKeys: dedupeKeys(rawPointKeys),
248
+ interpolate: spec.markDef.interpolate ?? 'monotone',
240
249
  topPoints,
241
250
  bottomPoints,
242
251
  path: pathStr,
@@ -402,8 +411,13 @@ function computeStackedArea(
402
411
  fillOpacity = 1;
403
412
  }
404
413
 
414
+ const rawPointKeys = validPoints.map((_p, idx) => serializeKeyValue(layer[idx]?.data.__x__));
415
+
405
416
  marks.push({
406
417
  type: 'area',
418
+ key: seriesKey,
419
+ pointKeys: dedupeKeys(rawPointKeys),
420
+ interpolate: spec.markDef.interpolate ?? 'monotone',
407
421
  topPoints,
408
422
  bottomPoints,
409
423
  path: pathStr,
@@ -464,6 +478,12 @@ export function computeAreaMarks(
464
478
  marks = computeSingleArea(spec, scales, chartArea);
465
479
  }
466
480
 
481
+ // Dedupe area mark keys
482
+ const areaKeys = dedupeKeys(marks.map((m) => m.key!));
483
+ marks.forEach((m, i) => {
484
+ m.key = areaKeys[i];
485
+ });
486
+
467
487
  const highlight = spec.highlight ?? [];
468
488
  if (highlight.length > 0) {
469
489
  const highlightSet = new Set(highlight);
@@ -20,6 +20,7 @@ import type {
20
20
  import { getRepresentativeColor } from '@opendata-ai/openchart-core';
21
21
  import { line } from 'd3-shape';
22
22
 
23
+ import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
23
24
  import type { NormalizedChartSpec } from '../../compiler/types';
24
25
  import type { ResolvedScales } from '../../layout/scales';
25
26
  import { getColor, getSequentialColor, groupByField, scaleValue, sortByField } from '../utils';
@@ -183,8 +184,16 @@ export function computeLineMarks(
183
184
  // Create the LineMark with the combined path points.
184
185
  // The points array includes all valid points across all segments.
185
186
  // dataPoints carries pixel coordinates + original data for voronoi tooltip overlay.
187
+ const keyField = encoding.key && 'field' in encoding.key ? encoding.key.field : undefined;
188
+ const rawPointKeys = pointsWithData.map((p) =>
189
+ keyField ? serializeKeyValue(p.row[keyField]) : serializeKeyValue(p.row[xChannel.field]),
190
+ );
191
+
186
192
  const lineMark: LineMark = {
187
193
  type: 'line',
194
+ key: seriesStyleKey ?? 'series',
195
+ pointKeys: dedupeKeys(rawPointKeys),
196
+ interpolate: spec.markDef.interpolate ?? 'monotone',
188
197
  points: allPoints,
189
198
  path: combinedPath,
190
199
  stroke: strokeColor,
@@ -242,9 +251,15 @@ export function computeLineMarks(
242
251
  // color — no white halo, no hollow ring. Marked decorative because the
243
252
  // data point already exists on the line and gets described by the a11y
244
253
  // data table; the dot is purely visual.
254
+ const pointKey = keyField
255
+ ? serializeKeyValue(p.row[keyField])
256
+ : seriesStyleKey
257
+ ? `${seriesStyleKey}|${serializeKeyValue(p.row[xChannel.field])}`
258
+ : serializeKeyValue(p.row[xChannel.field]);
245
259
  if (isSingleEndpoint) {
246
260
  bucket.push({
247
261
  type: 'point',
262
+ key: pointKey,
248
263
  cx: p.x,
249
264
  cy: p.y,
250
265
  r: 3.5,
@@ -259,6 +274,7 @@ export function computeLineMarks(
259
274
  }
260
275
  const pointMark: PointMark = {
261
276
  type: 'point',
277
+ key: pointKey,
262
278
  cx: p.x,
263
279
  cy: p.y,
264
280
  r: visible ? DEFAULT_POINT_RADIUS : 0,
@@ -276,7 +292,25 @@ export function computeLineMarks(
276
292
  }
277
293
  }
278
294
 
279
- return [...mutedMarks, ...highlightedMarks];
295
+ const allMarks = [...mutedMarks, ...highlightedMarks];
296
+
297
+ // Dedupe series-level keys across all line marks
298
+ const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line');
299
+ const lineKeys = dedupeKeys(lineMarks.map((m) => m.key!));
300
+ lineMarks.forEach((m, i) => {
301
+ m.key = lineKeys[i];
302
+ });
303
+
304
+ // Dedupe point mark keys
305
+ const pointMarks = allMarks.filter((m): m is PointMark => m.type === 'point');
306
+ if (pointMarks.length > 0) {
307
+ const pointKeys = dedupeKeys(pointMarks.map((m) => m.key ?? ''));
308
+ pointMarks.forEach((m, i) => {
309
+ m.key = pointKeys[i];
310
+ });
311
+ }
312
+
313
+ return allMarks;
280
314
  }
281
315
 
282
316
  // ---------------------------------------------------------------------------
@@ -1,13 +1,18 @@
1
1
  /**
2
- * Curve interpolation mapping.
2
+ * Curve interpolation mapping and path builders.
3
3
  *
4
4
  * Maps Vega-Lite-style interpolation strings to d3-shape curve factories.
5
5
  * Used by both line and area chart computations.
6
+ *
7
+ * Also exports pure path-builder helpers (`buildLinePath`, `buildAreaPath`)
8
+ * so downstream consumers (e.g. transition interpolators) can reconstruct
9
+ * SVG paths from point arrays without pulling in chart-specific code.
6
10
  */
7
11
 
8
- import type { MarkDef } from '@opendata-ai/openchart-core';
12
+ import type { MarkDef, Point } from '@opendata-ai/openchart-core';
9
13
  import type { CurveFactory } from 'd3-shape';
10
14
  import {
15
+ area,
11
16
  curveBasis,
12
17
  curveCardinal,
13
18
  curveLinear,
@@ -16,6 +21,7 @@ import {
16
21
  curveStep,
17
22
  curveStepAfter,
18
23
  curveStepBefore,
24
+ line,
19
25
  } from 'd3-shape';
20
26
 
21
27
  /** Map of interpolation string names to d3 curve factories. */
@@ -38,3 +44,42 @@ export function resolveCurve(interpolate?: MarkDef['interpolate']): CurveFactory
38
44
  if (!interpolate) return curveMonotoneX;
39
45
  return CURVE_MAP[interpolate] ?? curveMonotoneX;
40
46
  }
47
+
48
+ /**
49
+ * Build an SVG path string from an array of points using d3 line generator.
50
+ * Accepts an optional interpolation string (defaults to 'monotone').
51
+ */
52
+ export function buildLinePath(points: Point[], interpolate?: string): string {
53
+ if (points.length === 0) return '';
54
+ const curve = resolveCurve(interpolate as MarkDef['interpolate']);
55
+ const generator = line<Point>()
56
+ .x((d) => d.x)
57
+ .y((d) => d.y)
58
+ .curve(curve);
59
+ return generator(points) ?? '';
60
+ }
61
+
62
+ /**
63
+ * Build an SVG path string for a filled area from top and bottom point arrays.
64
+ * Accepts an optional interpolation string (defaults to 'monotone').
65
+ */
66
+ export function buildAreaPath(
67
+ topPoints: Point[],
68
+ bottomPoints: Point[],
69
+ interpolate?: string,
70
+ ): string {
71
+ if (topPoints.length === 0) return '';
72
+ const curve = resolveCurve(interpolate as MarkDef['interpolate']);
73
+ // Pair top and bottom points for the area generator
74
+ const paired = topPoints.map((tp, i) => ({
75
+ x: tp.x,
76
+ yTop: tp.y,
77
+ yBottom: bottomPoints[i]?.y ?? 0,
78
+ }));
79
+ const generator = area<{ x: number; yTop: number; yBottom: number }>()
80
+ .x((d) => d.x)
81
+ .y0((d) => d.yBottom)
82
+ .y1((d) => d.yTop)
83
+ .curve(curve);
84
+ return generator(paired) ?? '';
85
+ }
@@ -20,6 +20,7 @@ import { isConditionalDef, isGradientDef } from '@opendata-ai/openchart-core';
20
20
  import type { PieArcDatum } from 'd3-shape';
21
21
  import { arc as d3Arc, pie as d3Pie } from 'd3-shape';
22
22
 
23
+ import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
23
24
  import type { NormalizedChartSpec } from '../../compiler/types';
24
25
  import type { ResolvedScales } from '../../layout/scales';
25
26
  import { resolveConditionalValue } from '../../transforms/conditional';
@@ -252,5 +253,17 @@ export function computePieMarks(
252
253
  });
253
254
  }
254
255
 
256
+ // Stamp keys: slice label (category name) as the natural identity
257
+ const rawKeys = marks.map((m) => {
258
+ const label = categoryField
259
+ ? String(m.data[categoryField] ?? '')
260
+ : String(m.data.label ?? m.data.name ?? m.data.category ?? '');
261
+ return serializeKeyValue(label);
262
+ });
263
+ const keys = dedupeKeys(rawKeys);
264
+ for (let i = 0; i < marks.length; i++) {
265
+ marks[i].key = keys[i];
266
+ }
267
+
255
268
  return marks;
256
269
  }
@@ -188,11 +188,11 @@ export function assignAnimationIndices(
188
188
  marks: Mark[],
189
189
  animation: ResolvedAnimation | undefined,
190
190
  ): void {
191
- if (!animation?.enabled) return;
191
+ if (!animation?.enter) return;
192
192
 
193
193
  // Phase 1: Value-based stagger ordering. Skip stacked rects
194
194
  // since they get group-based indices below (avoids wasted work that gets overwritten).
195
- if (animation.staggerOrder === 'value') {
195
+ if (animation.enter.staggerOrder === 'value') {
196
196
  const indexed = marks.map((m, i) => ({ mark: m, idx: i }));
197
197
  indexed.sort((a, b) => {
198
198
  const av = getMarkPrimaryValue(a.mark);
@@ -9,6 +9,7 @@
9
9
  import type { Encoding, Mark, MarkAria, Rect, RuleMarkLayout } from '@opendata-ai/openchart-core';
10
10
  import { getRepresentativeColor } from '@opendata-ai/openchart-core';
11
11
 
12
+ import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
12
13
  import type { NormalizedChartSpec } from '../../compiler/types';
13
14
  import type { ResolvedScales } from '../../layout/scales';
14
15
  import type { ChartRenderer } from '../registry';
@@ -119,6 +120,14 @@ export function computeRuleMarks(
119
120
  });
120
121
  }
121
122
 
123
+ // Stamp keys: position-defining values, fall back to index
124
+ const posField = xChannel?.field ?? yChannel?.field;
125
+ const rawKeys = marks.map((m, i) => (posField ? serializeKeyValue(m.data[posField]) : String(i)));
126
+ const keys = dedupeKeys(rawKeys);
127
+ for (let i = 0; i < marks.length; i++) {
128
+ marks[i].key = keys[i];
129
+ }
130
+
122
131
  return marks;
123
132
  }
124
133
 
@@ -20,6 +20,7 @@ import { max, min } from 'd3-array';
20
20
  import type { ScaleBand, ScaleLinear, ScalePoint, ScaleTime } from 'd3-scale';
21
21
  import { scaleSqrt } from 'd3-scale';
22
22
 
23
+ import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
23
24
  import type { NormalizedChartSpec } from '../../compiler/types';
24
25
  import type { ResolvedScales } from '../../layout/scales';
25
26
  import { getColor, getSequentialColor } from '../utils';
@@ -133,6 +134,8 @@ export function computeScatterMarks(
133
134
  sizeScale = scaleSqrt().domain([sizeMin, sizeMax]).range([radiusMin, radiusMax]).clamp(true);
134
135
  }
135
136
 
137
+ const keyEnc = encoding.key && 'field' in encoding.key ? encoding.key : undefined;
138
+ const keyField = keyEnc?.field;
136
139
  const marks: PointMark[] = [];
137
140
 
138
141
  for (const row of spec.data) {
@@ -187,5 +190,17 @@ export function computeScatterMarks(
187
190
  });
188
191
  }
189
192
 
193
+ // Stamp keys: encoding.key field when set, else seriesKey|x composite
194
+ const rawKeys = marks.map((m) => {
195
+ if (keyField) return serializeKeyValue(m.data[keyField]);
196
+ const cat = colorField ? String(m.data[colorField] ?? '') : '';
197
+ const xVal = serializeKeyValue(m.data[xChannel.field]);
198
+ return cat ? `${cat}|${xVal}` : xVal;
199
+ });
200
+ const keys = dedupeKeys(rawKeys);
201
+ for (let i = 0; i < marks.length; i++) {
202
+ marks[i].key = keys[i];
203
+ }
204
+
190
205
  return marks;
191
206
  }
@@ -9,6 +9,7 @@
9
9
  import type { Encoding, Mark, MarkAria, TextMarkLayout } from '@opendata-ai/openchart-core';
10
10
  import { getRepresentativeColor } from '@opendata-ai/openchart-core';
11
11
 
12
+ import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
12
13
  import type { NormalizedChartSpec } from '../../compiler/types';
13
14
  import type { ResolvedScales } from '../../layout/scales';
14
15
  import type { ChartRenderer } from '../registry';
@@ -84,6 +85,14 @@ export function computeTextMarks(
84
85
  });
85
86
  }
86
87
 
88
+ // Stamp keys: position-defining value, fall back to index
89
+ const posField = xChannel?.field ?? yChannel?.field;
90
+ const rawKeys = marks.map((m, i) => (posField ? serializeKeyValue(m.data[posField]) : String(i)));
91
+ const keys = dedupeKeys(rawKeys);
92
+ for (let i = 0; i < marks.length; i++) {
93
+ marks[i].key = keys[i];
94
+ }
95
+
87
96
  return marks;
88
97
  }
89
98
 
@@ -9,6 +9,7 @@
9
9
  import type { Encoding, Mark, MarkAria, Rect, TickMarkLayout } from '@opendata-ai/openchart-core';
10
10
  import { getRepresentativeColor } from '@opendata-ai/openchart-core';
11
11
 
12
+ import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
12
13
  import type { NormalizedChartSpec } from '../../compiler/types';
13
14
  import type { ResolvedScales } from '../../layout/scales';
14
15
  import type { ChartRenderer } from '../registry';
@@ -76,6 +77,14 @@ export function computeTickMarks(
76
77
  });
77
78
  }
78
79
 
80
+ // Stamp keys: position-defining value, fall back to index
81
+ const posField = isHorizontal ? xChannel.field : yChannel.field;
82
+ const rawKeys = marks.map((m) => serializeKeyValue(m.data[posField]));
83
+ const keys = dedupeKeys(rawKeys);
84
+ for (let i = 0; i < marks.length; i++) {
85
+ marks[i].key = keys[i];
86
+ }
87
+
79
88
  return marks;
80
89
  }
81
90
 
@@ -6,7 +6,13 @@
6
6
  */
7
7
 
8
8
  import { describe, expect, it } from 'vitest';
9
- import { clampStaggerDelay, resolveAnimation } from '../animation';
9
+ import {
10
+ clampStaggerDelay,
11
+ ENTER_DEFAULTS,
12
+ EXIT_DEFAULTS,
13
+ resolveAnimation,
14
+ UPDATE_DEFAULTS,
15
+ } from '../animation';
10
16
 
11
17
  // ---------------------------------------------------------------------------
12
18
  // resolveAnimation
@@ -21,14 +27,12 @@ describe('resolveAnimation', () => {
21
27
  expect(resolveAnimation(undefined)).toBeUndefined();
22
28
  });
23
29
 
24
- it('returns defaults for true', () => {
30
+ it('returns all three phases with defaults for true', () => {
25
31
  const result = resolveAnimation(true);
26
32
  expect(result).toEqual({
27
- enabled: true,
28
- duration: 500,
29
- ease: 'smooth',
30
- staggerDelay: 80,
31
- staggerOrder: 'index',
33
+ enter: { ...ENTER_DEFAULTS },
34
+ update: { ...UPDATE_DEFAULTS },
35
+ exit: { ...EXIT_DEFAULTS },
32
36
  annotationDelay: 200,
33
37
  });
34
38
  });
@@ -36,11 +40,9 @@ describe('resolveAnimation', () => {
36
40
  it('resolves AnimationConfig with enter: true', () => {
37
41
  const result = resolveAnimation({ enter: true });
38
42
  expect(result).toBeDefined();
39
- expect(result!.enabled).toBe(true);
40
- expect(result!.duration).toBe(500);
41
- expect(result!.ease).toBe('smooth');
42
- expect(result!.staggerDelay).toBe(80);
43
- expect(result!.staggerOrder).toBe('index');
43
+ expect(result!.enter).toEqual(ENTER_DEFAULTS);
44
+ expect(result!.update).toEqual(UPDATE_DEFAULTS);
45
+ expect(result!.exit).toEqual(EXIT_DEFAULTS);
44
46
  expect(result!.annotationDelay).toBe(200);
45
47
  });
46
48
 
@@ -53,20 +55,41 @@ describe('resolveAnimation', () => {
53
55
  },
54
56
  });
55
57
  expect(result).toBeDefined();
56
- expect(result!.duration).toBe(800);
57
- expect(result!.ease).toBe('smooth');
58
- expect(result!.staggerDelay).toBe(40);
59
- expect(result!.staggerOrder).toBe('value');
58
+ expect(result!.enter).toEqual({
59
+ duration: 800,
60
+ ease: 'smooth',
61
+ staggerDelay: 40,
62
+ staggerOrder: 'value',
63
+ });
64
+ // Other phases still present with defaults
65
+ expect(result!.update).toEqual(UPDATE_DEFAULTS);
66
+ expect(result!.exit).toEqual(EXIT_DEFAULTS);
60
67
  });
61
68
 
62
- it('returns undefined when enter is false', () => {
63
- expect(resolveAnimation({ enter: false })).toBeUndefined();
69
+ it('enter: false, update: true produces update present, no enter', () => {
70
+ const result = resolveAnimation({ enter: false, update: true });
71
+ expect(result).toBeDefined();
72
+ expect(result!.enter).toBeUndefined();
73
+ expect(result!.update).toEqual(UPDATE_DEFAULTS);
74
+ expect(result!.exit).toEqual(EXIT_DEFAULTS);
75
+ });
76
+
77
+ it('update: false produces enter + exit present, update absent', () => {
78
+ const result = resolveAnimation({ update: false });
79
+ expect(result).toBeDefined();
80
+ expect(result!.enter).toEqual(ENTER_DEFAULTS);
81
+ expect(result!.update).toBeUndefined();
82
+ expect(result!.exit).toEqual(EXIT_DEFAULTS);
83
+ });
84
+
85
+ it('all phases false returns undefined', () => {
86
+ expect(resolveAnimation({ enter: false, update: false, exit: false })).toBeUndefined();
64
87
  });
65
88
 
66
89
  it('resolves stagger: false to staggerDelay: 0', () => {
67
90
  const result = resolveAnimation({ enter: { stagger: false } });
68
91
  expect(result).toBeDefined();
69
- expect(result!.staggerDelay).toBe(0);
92
+ expect(result!.enter!.staggerDelay).toBe(0);
70
93
  });
71
94
 
72
95
  it('uses custom annotationDelay', () => {
@@ -75,15 +98,48 @@ describe('resolveAnimation', () => {
75
98
  expect(result!.annotationDelay).toBe(500);
76
99
  });
77
100
 
78
- it('returns undefined when no phase is specified', () => {
79
- // Empty config with no enter/update/exit should not produce animation
80
- expect(resolveAnimation({})).toBeUndefined();
101
+ it('empty config resolves all phases with defaults (behaves like true)', () => {
102
+ const result = resolveAnimation({});
103
+ expect(result).toBeDefined();
104
+ expect(result!.enter).toEqual(ENTER_DEFAULTS);
105
+ expect(result!.update).toEqual(UPDATE_DEFAULTS);
106
+ expect(result!.exit).toEqual(EXIT_DEFAULTS);
81
107
  });
82
108
 
83
109
  it('preserves default annotationDelay when not overridden', () => {
84
110
  const result = resolveAnimation({ enter: { duration: 1000 } });
85
111
  expect(result!.annotationDelay).toBe(200);
86
112
  });
113
+
114
+ it('enter phase uses ENTER_DEFAULTS for unspecified fields', () => {
115
+ const result = resolveAnimation({ enter: { duration: 1000 } });
116
+ expect(result!.enter).toEqual({
117
+ duration: 1000,
118
+ ease: 'smooth',
119
+ staggerDelay: 80,
120
+ staggerOrder: 'index',
121
+ });
122
+ });
123
+
124
+ it('update phase uses UPDATE_DEFAULTS (no stagger by default)', () => {
125
+ const result = resolveAnimation(true);
126
+ expect(result!.update).toEqual({
127
+ duration: 500,
128
+ ease: 'smooth',
129
+ staggerDelay: 0,
130
+ staggerOrder: 'index',
131
+ });
132
+ });
133
+
134
+ it('exit phase uses EXIT_DEFAULTS (shorter duration)', () => {
135
+ const result = resolveAnimation(true);
136
+ expect(result!.exit).toEqual({
137
+ duration: 300,
138
+ ease: 'smooth',
139
+ staggerDelay: 0,
140
+ staggerOrder: 'index',
141
+ });
142
+ });
87
143
  });
88
144
 
89
145
  // ---------------------------------------------------------------------------
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Tests for mark key utilities: serializeKeyValue() and dedupeKeys().
3
+ */
4
+
5
+ import { describe, expect, it } from 'vitest';
6
+ import { dedupeKeys, serializeKeyValue } from '../keys';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // serializeKeyValue
10
+ // ---------------------------------------------------------------------------
11
+
12
+ describe('serializeKeyValue', () => {
13
+ it('serializes strings as-is', () => {
14
+ expect(serializeKeyValue('hello')).toBe('hello');
15
+ expect(serializeKeyValue('')).toBe('');
16
+ });
17
+
18
+ it('serializes numbers to string', () => {
19
+ expect(serializeKeyValue(42)).toBe('42');
20
+ expect(serializeKeyValue(0)).toBe('0');
21
+ expect(serializeKeyValue(-3.14)).toBe('-3.14');
22
+ });
23
+
24
+ it('serializes booleans to string', () => {
25
+ expect(serializeKeyValue(true)).toBe('true');
26
+ expect(serializeKeyValue(false)).toBe('false');
27
+ });
28
+
29
+ it('serializes null to sentinel', () => {
30
+ expect(serializeKeyValue(null)).toBe('∅');
31
+ });
32
+
33
+ it('serializes undefined to sentinel', () => {
34
+ expect(serializeKeyValue(undefined)).toBe('∅');
35
+ });
36
+
37
+ it('serializes Date to epoch ms (not locale string)', () => {
38
+ const d = new Date('2024-01-15T00:00:00Z');
39
+ const result = serializeKeyValue(d);
40
+ expect(result).toBe(String(d.getTime()));
41
+ // Confirm it's purely numeric
42
+ expect(Number(result)).toBe(d.getTime());
43
+ // Confirm it does NOT use Date.toString
44
+ expect(result).not.toContain('Jan');
45
+ expect(result).not.toContain('Mon');
46
+ });
47
+
48
+ it('two Dates with same epoch produce same key', () => {
49
+ const d1 = new Date('2024-06-01T12:00:00Z');
50
+ const d2 = new Date(d1.getTime());
51
+ expect(serializeKeyValue(d1)).toBe(serializeKeyValue(d2));
52
+ });
53
+ });
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // dedupeKeys
57
+ // ---------------------------------------------------------------------------
58
+
59
+ describe('dedupeKeys', () => {
60
+ it('passes unique keys through unchanged', () => {
61
+ expect(dedupeKeys(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']);
62
+ });
63
+
64
+ it('suffixes duplicates with occurrence index', () => {
65
+ expect(dedupeKeys(['a', 'a', 'a'])).toEqual(['a\x010', 'a\x011', 'a\x012']);
66
+ });
67
+
68
+ it('only suffixes keys that appear more than once', () => {
69
+ expect(dedupeKeys(['a', 'b', 'a'])).toEqual(['a\x010', 'b', 'a\x011']);
70
+ });
71
+
72
+ it('handles empty array', () => {
73
+ expect(dedupeKeys([])).toEqual([]);
74
+ });
75
+
76
+ it('handles single element', () => {
77
+ expect(dedupeKeys(['x'])).toEqual(['x']);
78
+ });
79
+
80
+ it('handles multiple duplicate groups', () => {
81
+ expect(dedupeKeys(['x', 'y', 'x', 'y', 'z'])).toEqual([
82
+ 'x\x010',
83
+ 'y\x010',
84
+ 'x\x011',
85
+ 'y\x011',
86
+ 'z',
87
+ ]);
88
+ });
89
+ });