@cdc/core 4.25.11 → 4.26.2

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.
Files changed (147) hide show
  1. package/.claude/agents/qa-test-developer.md +126 -0
  2. package/CLAUDE.local.md +67 -0
  3. package/_stories/Gallery.Charts.stories.tsx +300 -0
  4. package/_stories/Gallery.DataBite.stories.tsx +79 -0
  5. package/_stories/Gallery.Maps.stories.tsx +239 -0
  6. package/_stories/Gallery.WaffleChart.stories.tsx +187 -0
  7. package/_stories/PageART.stories.tsx +193 -0
  8. package/_stories/PageBRFSS.stories.tsx +294 -0
  9. package/_stories/PageCancerRegistries.stories.tsx +199 -0
  10. package/_stories/PageEasternEquineEncephalitis.stories.tsx +216 -0
  11. package/_stories/PageExcessiveAlcoholUse.stories.tsx +201 -0
  12. package/_stories/PageMaternalMortality.stories.tsx +193 -0
  13. package/_stories/PageOralHealth.stories.tsx +201 -0
  14. package/_stories/PageRespiratory.stories.tsx +332 -0
  15. package/_stories/PageSmokingTobacco.stories.tsx +200 -0
  16. package/_stories/PageStateDiabetesProfiles.stories.tsx +201 -0
  17. package/_stories/PageWastewater.stories.tsx +477 -0
  18. package/_stories/VegaImport.stories.tsx +401 -0
  19. package/_stories/vega-fixtures/bars-with-line.json +444 -0
  20. package/_stories/vega-fixtures/bars.json +58 -0
  21. package/_stories/vega-fixtures/combo-bar-rolling-mean.json +88 -0
  22. package/_stories/vega-fixtures/combo.json +68 -0
  23. package/_stories/vega-fixtures/grouped-horizontal-bars.json +83 -0
  24. package/_stories/vega-fixtures/grouped-horizontal-bars2.json +231 -0
  25. package/_stories/vega-fixtures/horizontal-bar.json +427 -0
  26. package/_stories/vega-fixtures/horizontal-bars-with-bad-colors.json +197 -0
  27. package/_stories/vega-fixtures/horizontal-bars2.json +58 -0
  28. package/_stories/vega-fixtures/lines.json +227 -0
  29. package/_stories/vega-fixtures/measles-bars.json +348 -0
  30. package/_stories/vega-fixtures/measles-map.json +11101 -0
  31. package/_stories/vega-fixtures/measles-stacked-bars.json +2147 -0
  32. package/_stories/vega-fixtures/multi-dataset.json +255 -0
  33. package/_stories/vega-fixtures/no-data.json +14 -0
  34. package/_stories/vega-fixtures/pie-chart.json +94 -0
  35. package/_stories/vega-fixtures/repeat-spec.json +47 -0
  36. package/_stories/vega-fixtures/stacked-area.json +222 -0
  37. package/_stories/vega-fixtures/stacked-bar-with-rect.json +3412 -0
  38. package/_stories/vega-fixtures/stacked-bars-with-line.json +364 -0
  39. package/_stories/vega-fixtures/stacked-bars.json +212 -0
  40. package/_stories/vega-fixtures/stacked-horizontal-bars.json +140 -0
  41. package/_stories/vega-fixtures/warning-combo.json +59 -0
  42. package/_stories/vega-fixtures/warning-scatter-and-line.json +1182 -0
  43. package/assets/icon-chart-area.svg +1 -0
  44. package/assets/icon-chart-radar.svg +23 -0
  45. package/assets/icon-magnifying-glass.svg +5 -0
  46. package/assets/icon-warming-stripes.svg +13 -0
  47. package/assets/logo2.svg +31 -0
  48. package/components/AdvancedEditor/AdvancedEditor.tsx +4 -0
  49. package/components/AdvancedEditor/EmbedEditor.tsx +513 -0
  50. package/components/ComboBox/ComboBox.tsx +345 -0
  51. package/components/ComboBox/combobox.styles.css +185 -0
  52. package/components/ComboBox/index.ts +1 -0
  53. package/components/CustomColorsEditor/CustomColorsEditor.tsx +3 -10
  54. package/components/DataTable/DataTable.tsx +132 -58
  55. package/components/DataTable/data-table.css +216 -215
  56. package/components/DataTable/helpers/getSeriesName.ts +6 -0
  57. package/components/DataTable/helpers/mapCellMatrix.tsx +14 -6
  58. package/components/EditorPanel/ColumnsEditor.tsx +37 -19
  59. package/components/EditorPanel/DataTableEditor.tsx +51 -25
  60. package/components/EditorPanel/EditorPanel.styles.css +16 -0
  61. package/components/EditorPanel/EditorPanel.tsx +144 -0
  62. package/components/EditorPanel/EditorPanelDispatch.tsx +75 -0
  63. package/components/EditorPanel/FieldSetWrapper.tsx +66 -23
  64. package/components/EditorPanel/Inputs.tsx +33 -7
  65. package/components/EditorPanel/VizFilterEditor/NestedDropdownEditor.tsx +14 -6
  66. package/components/EditorPanel/VizFilterEditor/VizFilterEditor.tsx +240 -175
  67. package/components/EditorPanel/VizFilterEditor/components/FilterOrder.tsx +33 -29
  68. package/components/EditorPanel/sections/VisualSection.tsx +169 -0
  69. package/components/Filters/Filters.tsx +31 -5
  70. package/components/Filters/helpers/getNestedOptions.ts +2 -1
  71. package/components/Filters/helpers/handleSorting.ts +1 -1
  72. package/components/Layout/components/Sidebar/components/sidebar.styles.scss +84 -2
  73. package/components/Layout/components/Visualization/index.tsx +27 -1
  74. package/components/Layout/components/Visualization/visualizations.scss +7 -0
  75. package/components/Legend/Legend.Gradient.tsx +1 -1
  76. package/components/MediaControls.tsx +53 -28
  77. package/components/_stories/CustomColorsEditor.stories.tsx +37 -0
  78. package/components/_stories/DataTable.stories.tsx +1 -0
  79. package/components/ui/Icon.tsx +3 -1
  80. package/components/ui/Title/index.tsx +30 -2
  81. package/components/ui/Title/title.styles.css +42 -0
  82. package/data/colorPalettes.ts +18 -5
  83. package/data/mapColorPalettes.ts +10 -0
  84. package/devTemplate/dev.js +235 -0
  85. package/devTemplate/index.html +30 -0
  86. package/devTemplate/preview.html +1503 -0
  87. package/devTemplate/sidebar.css +151 -0
  88. package/dist/cove-main.css +2803 -4448
  89. package/dist/cove-main.css.map +1 -1
  90. package/generateViteConfig.js +118 -2
  91. package/helpers/DataTransform.ts +1 -5
  92. package/helpers/addValuesToFilters.ts +6 -1
  93. package/helpers/cove/date.ts +33 -1
  94. package/helpers/cove/string.ts +29 -0
  95. package/helpers/coveUpdateWorker.ts +21 -12
  96. package/helpers/embed/embedCodeGenerator.ts +80 -0
  97. package/helpers/embed/embedHelper.js +158 -0
  98. package/helpers/embed/filterUtils.ts +121 -0
  99. package/helpers/embed/index.ts +21 -0
  100. package/helpers/embed/urlValidation.ts +119 -0
  101. package/helpers/filterVizData.ts +6 -1
  102. package/helpers/getFileExtension.ts +0 -6
  103. package/helpers/getUniqueValues.ts +19 -0
  104. package/helpers/hashObj.ts +25 -0
  105. package/helpers/isRightAlignedTableValue.js +5 -0
  106. package/helpers/metrics/helpers.ts +1 -0
  107. package/helpers/metrics/types.ts +3 -0
  108. package/helpers/palettes/colorDistributions.ts +1 -1
  109. package/helpers/palettes/utils.ts +12 -12
  110. package/helpers/parseCsvWithQuotes.ts +15 -14
  111. package/helpers/pivotData.ts +2 -2
  112. package/helpers/prepareScreenshot.ts +288 -0
  113. package/helpers/queryStringUtils.ts +29 -0
  114. package/helpers/testing.ts +44 -0
  115. package/helpers/tests/DataTransform.test.ts +125 -0
  116. package/helpers/tests/date.test.ts +64 -0
  117. package/helpers/tests/prepareScreenshot.test.ts +414 -0
  118. package/helpers/tests/queryStringUtils.test.ts +381 -0
  119. package/helpers/tests/testStandaloneBuild.ts +23 -5
  120. package/helpers/useDataVizClasses.ts +0 -1
  121. package/helpers/vegaConfig.ts +1 -1
  122. package/helpers/vegaConfigImport.ts +160 -0
  123. package/helpers/ver/4.26.1.ts +80 -0
  124. package/helpers/ver/4.26.2.ts +84 -0
  125. package/helpers/ver/tests/4.26.1.test.ts +105 -0
  126. package/helpers/ver/tests/4.26.2.test.ts +298 -0
  127. package/helpers/viewports.ts +2 -0
  128. package/hooks/useDataColumns.ts +63 -0
  129. package/hooks/useFilterManagement.ts +94 -0
  130. package/hooks/useLegendSeparators.ts +26 -0
  131. package/hooks/useListManagement.ts +192 -0
  132. package/package.json +29 -33
  133. package/styles/_button-section.scss +0 -3
  134. package/styles/v2/components/editor.scss +9 -9
  135. package/styles/v2/utils/_grid.scss +8 -3
  136. package/types/Annotation.ts +10 -11
  137. package/types/Axis.ts +1 -0
  138. package/types/ForecastingSeriesKey.ts +1 -0
  139. package/types/General.ts +2 -0
  140. package/types/MarkupInclude.ts +1 -0
  141. package/types/Palette.ts +21 -0
  142. package/types/Series.ts +3 -0
  143. package/types/Table.ts +1 -0
  144. package/types/Visualization.ts +7 -0
  145. package/types/VizFilter.ts +1 -0
  146. package/LICENSE +0 -201
  147. package/_stories/StoryRenderingTests.stories.tsx +0 -164
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Helpers for preparing screenshot containers for image downloads
3
+ */
4
+
5
+ export interface ClonedElements {
6
+ clonedTree: HTMLElement
7
+ clonedViz: HTMLElement
8
+ }
9
+
10
+ interface VisualizationWrapper {
11
+ parentChildren: Element[]
12
+ wrapperIndex: number
13
+ }
14
+
15
+ /**
16
+ * Walk up from viz to find a parent container that has:
17
+ * - Multiple children
18
+ * - At least one child (before viz) that contains H2/H3 but NOT the viz
19
+ *
20
+ * Stops climbing at section boundaries (never goes above <section> or .dfe-section)
21
+ */
22
+ function findParentWithContext(viz: HTMLElement): HTMLElement | null {
23
+ let current = viz.parentElement
24
+
25
+ while (current) {
26
+ const isSection = current.tagName === 'SECTION' || current.classList.contains('dfe-section')
27
+
28
+ const children = Array.from(current.children)
29
+
30
+ // Need at least 2 children (heading + viz wrapper)
31
+ if (children.length < 2) {
32
+ if (isSection) {
33
+ return null
34
+ }
35
+ current = current.parentElement
36
+ continue
37
+ }
38
+
39
+ // Find which child contains the viz (guaranteed to exist since we're walking up from viz)
40
+ const vizChildIndex = children.findIndex(child => child.contains(viz))
41
+
42
+ // Check children before the viz for heading
43
+ for (let i = 0; i < vizChildIndex; i++) {
44
+ const child = children[i] as HTMLElement
45
+ const isHeading = child.tagName === 'H2' || child.tagName === 'H3'
46
+ const containsHeading = child.querySelector('h2, h3') !== null
47
+
48
+ if (isHeading || containsHeading) {
49
+ return current
50
+ }
51
+ }
52
+
53
+ // Don't climb above section boundaries
54
+ if (isSection) {
55
+ return null
56
+ }
57
+
58
+ // No suitable heading found in this parent's children, go up
59
+ current = current.parentElement
60
+ }
61
+
62
+ return null
63
+ }
64
+
65
+ /**
66
+ * Find which direct child of the parent container contains the visualization
67
+ */
68
+ function findVisualizationWrapper(viz: HTMLElement, parent: Element): VisualizationWrapper {
69
+ const parentChildren = Array.from(parent.children)
70
+ const wrapperIndex = parentChildren.findIndex(child => child.contains(viz))
71
+ return { parentChildren, wrapperIndex }
72
+ }
73
+
74
+ /**
75
+ * Walk backwards from viz to find nearest H2 or H3
76
+ * Stops early if another visualization is encountered
77
+ */
78
+ function findNearestHeadingIndex(parentChildren: Element[], vizWrapperIndex: number): number {
79
+ for (let i = vizWrapperIndex - 1; i >= 0; i--) {
80
+ const child = parentChildren[i] as HTMLElement
81
+
82
+ // STOP: Another visualization found - don't include its content
83
+ if (child.classList.contains('cdc-open-viz-module') || child.querySelector('.cdc-open-viz-module')) {
84
+ return -1
85
+ }
86
+
87
+ // Found direct H2/H3
88
+ if (child.tagName === 'H2' || child.tagName === 'H3') {
89
+ return i
90
+ }
91
+
92
+ // Found nested H2/H3
93
+ if (child.querySelector('h2, h3')) {
94
+ return i
95
+ }
96
+ }
97
+
98
+ return -1 // No heading found
99
+ }
100
+
101
+ /**
102
+ * Build cloned parent container with only selected children (heading through viz)
103
+ */
104
+ function buildContextClone(
105
+ parent: Element,
106
+ headingIndex: number,
107
+ vizWrapperIndex: number,
108
+ elementToCapture: string
109
+ ): ClonedElements {
110
+ const parentChildren = Array.from(parent.children)
111
+
112
+ // Shallow clone parent (preserves classes/styling)
113
+ const clonedTree = parent.cloneNode(false) as HTMLElement
114
+
115
+ // Append only selected children (heading → viz wrapper)
116
+ for (let i = headingIndex; i <= vizWrapperIndex; i++) {
117
+ clonedTree.appendChild(parentChildren[i].cloneNode(true))
118
+ }
119
+
120
+ // Find viz in cloned tree
121
+ const clonedViz = clonedTree.querySelector(`[data-download-id="${elementToCapture}"]`) as HTMLElement
122
+
123
+ // Container styling adjustments
124
+ clonedTree.style.marginBottom = '0'
125
+
126
+ // Remove top margin from context headings (not inside viz)
127
+ const allHeadings = clonedTree.querySelectorAll('h2, h3')
128
+ allHeadings.forEach(heading => {
129
+ if (!clonedViz.contains(heading)) {
130
+ ;(heading as HTMLElement).style.marginTop = '0'
131
+ }
132
+ })
133
+
134
+ return { clonedTree, clonedViz }
135
+ }
136
+
137
+ function isInEditorMode(element: HTMLElement): boolean {
138
+ return element.closest('.cdc-open-viz-module.isEditor') !== null
139
+ }
140
+
141
+ /**
142
+ * Prepare cloned elements (with or without context)
143
+ * Returns viz-only clone as fallback for any failure
144
+ * Exported for testing
145
+ */
146
+ export function prepareClonedElements(
147
+ baseSvg: HTMLElement,
148
+ includeContextInDownload: boolean,
149
+ elementToCapture: string
150
+ ): ClonedElements {
151
+ const defaultClone = (): ClonedElements => {
152
+ const tree = baseSvg.cloneNode(true) as HTMLElement
153
+ return { clonedTree: tree, clonedViz: tree }
154
+ }
155
+
156
+ // Early return: context not requested or in editor mode (editor downloads should not include context)
157
+ if (!includeContextInDownload || isInEditorMode(baseSvg)) {
158
+ return defaultClone()
159
+ }
160
+
161
+ // Early return: no parent with context found
162
+ const parent = findParentWithContext(baseSvg)
163
+ if (!parent) {
164
+ return defaultClone()
165
+ }
166
+
167
+ // Get viz wrapper (guaranteed to exist since findParentWithContext found it)
168
+ const { parentChildren, wrapperIndex } = findVisualizationWrapper(baseSvg, parent as Element)
169
+
170
+ // Early return: no heading found (or another viz in the way)
171
+ const headingIndex = findNearestHeadingIndex(parentChildren, wrapperIndex)
172
+ if (headingIndex < 0) {
173
+ return defaultClone()
174
+ }
175
+
176
+ // Build with context
177
+ return buildContextClone(parent, headingIndex, wrapperIndex, elementToCapture)
178
+ }
179
+
180
+ /**
181
+ * Strip all <a> tags from cloned tree (links aren't clickable in PNG)
182
+ */
183
+ function stripLinks(clonedTree: HTMLElement): void {
184
+ const allLinks = clonedTree.querySelectorAll('a')
185
+ allLinks.forEach(link => {
186
+ const textNode = document.createTextNode(link.textContent || '')
187
+ link.parentNode?.replaceChild(textNode, link)
188
+ })
189
+ }
190
+
191
+ /**
192
+ * Convert canvas elements to images (canvas pixel data doesn't clone)
193
+ */
194
+ function convertCanvasToImages(baseSvg: HTMLElement, clonedViz: HTMLElement): void {
195
+ const originalCanvases = baseSvg.querySelectorAll('canvas')
196
+ const clonedCanvases = clonedViz.querySelectorAll('canvas')
197
+
198
+ clonedCanvases.forEach((clonedCanvas, index) => {
199
+ const originalCanvas = originalCanvases[index] as HTMLCanvasElement
200
+ if (originalCanvas && originalCanvas.width > 0 && originalCanvas.height > 0) {
201
+ const img = document.createElement('img')
202
+ img.src = originalCanvas.toDataURL('image/png')
203
+ img.width = originalCanvas.width
204
+ img.height = originalCanvas.height
205
+ img.className = originalCanvas.className
206
+ clonedCanvas.parentNode?.replaceChild(img, clonedCanvas)
207
+ }
208
+ })
209
+ }
210
+
211
+ /**
212
+ * Remove width classes that interfere with screenshot sizing
213
+ */
214
+ function removeWidthClasses(clonedTree: HTMLElement): void {
215
+ const classesToRemove = ['dfe-block--width-wide', 'dfe-block--width-full_width']
216
+
217
+ classesToRemove.forEach(className => {
218
+ const elements = clonedTree.querySelectorAll(`.${className}`)
219
+ elements.forEach(el => el.classList.remove(className))
220
+ })
221
+
222
+ // Also check the root element itself
223
+ classesToRemove.forEach(className => {
224
+ clonedTree.classList.remove(className)
225
+ })
226
+ }
227
+
228
+ /**
229
+ * Expand SVG widths and remove animation classes
230
+ */
231
+ function expandSvgWidths(clonedViz: HTMLElement): void {
232
+ const svgWidthBuffer = 25
233
+ const svgElements = clonedViz.querySelectorAll('svg')
234
+
235
+ svgElements.forEach(svg => {
236
+ const currentWidth = parseInt(svg.getAttribute('width') || '0')
237
+ if (currentWidth > 0) {
238
+ svg.setAttribute('width', (currentWidth + svgWidthBuffer).toString())
239
+ }
240
+
241
+ // Remove animation classes to show final state immediately
242
+ svg.classList.remove('animated', 'animate')
243
+ })
244
+ }
245
+
246
+ /**
247
+ * Main function: Prepare a complete container ready for html2canvas screenshot
248
+ */
249
+ export function prepareScreenshotContainer(
250
+ baseSvg: HTMLElement,
251
+ includeContextInDownload: boolean,
252
+ elementToCapture: string
253
+ ): HTMLDivElement {
254
+ // 1. Clone elements (with or without context)
255
+ const { clonedTree, clonedViz } = prepareClonedElements(baseSvg, includeContextInDownload, elementToCapture)
256
+
257
+ // 2. Remove width classes that interfere with screenshot sizing
258
+ removeWidthClasses(clonedTree)
259
+
260
+ // 3. Strip all links (not clickable in static image)
261
+ stripLinks(clonedTree)
262
+
263
+ // 4. Convert canvas elements to images
264
+ convertCanvasToImages(baseSvg, clonedViz)
265
+
266
+ // 5. Expand SVG widths to prevent clipping
267
+ expandSvgWidths(clonedViz)
268
+
269
+ // 6. Calculate viz dimensions
270
+ const computedStyle = getComputedStyle(baseSvg)
271
+ const vizWidth =
272
+ parseFloat(computedStyle.width) -
273
+ (parseFloat(computedStyle.paddingLeft) || 0) -
274
+ (parseFloat(computedStyle.paddingRight) || 0)
275
+
276
+ // 7. Create and style container
277
+ const container = document.createElement('div')
278
+ container.style.width = `${vizWidth + 36}px`
279
+ container.style.padding = '18px'
280
+
281
+ // 8. Reset viz padding
282
+ clonedViz.style.padding = '0'
283
+
284
+ // 9. Append cloned tree to container
285
+ container.appendChild(clonedTree)
286
+
287
+ return container
288
+ }
@@ -13,6 +13,35 @@ export function getQueryStringFilterValue(filter) {
13
13
  }
14
14
  }
15
15
 
16
+ /**
17
+ * Check if a filter should be hidden based on URL query parameters
18
+ * Checks for hide{setByQueryParameter}=true|1|yes in the URL
19
+ *
20
+ * @param filter - Filter object with setByQueryParameter property
21
+ * @returns true if filter should be hidden, false otherwise
22
+ *
23
+ * @example
24
+ * // If filter has setByQueryParameter: "state"
25
+ * // URL: ?hidestate=true -> returns true
26
+ * // URL: ?hideState=true -> returns false (case sensitive!)
27
+ */
28
+ export function isFilterHiddenByQuery(filter) {
29
+ if (!filter) return false
30
+
31
+ // Only check setByQueryParameter - this is the standard way filters are identified in URL params
32
+ const paramName = filter.setByQueryParameter
33
+ if (!paramName) return false
34
+
35
+ const urlParams = new URLSearchParams(window.location.search)
36
+ const hideParamName = `hide${paramName}`
37
+ const hideValue = urlParams.get(hideParamName)
38
+
39
+ if (!hideValue) return false
40
+
41
+ const lower = hideValue.toLowerCase()
42
+ return lower === 'true' || hideValue === '1' || lower === 'yes'
43
+ }
44
+
16
45
  export function getQueryParams() {
17
46
  const queryParams = {}
18
47
  for (const [key, value] of Array.from(new URLSearchParams(window.location.search).entries())) {
@@ -313,6 +313,50 @@ export const testBooleanControl = async (checkbox: HTMLInputElement, getVisualSt
313
313
  expect(secondToggleVisualState).toEqual(initialVisualState)
314
314
  }
315
315
 
316
+ // ============================================================================
317
+ // VISUALIZATION RENDERING ASSERTIONS
318
+ // ============================================================================
319
+
320
+ /**
321
+ * Assert that a visualization has rendered successfully.
322
+ * Uses performAndAssert to poll until one of these conditions is met:
323
+ * ((svgCount > 0 || canvasCount > 0) && hasCoveModule) || isDataBite || isDataTable
324
+ *
325
+ * Handles all visualization types:
326
+ * - Charts (SVG-based)
327
+ * - Maps with SVG rendering (US state, world, hex, etc.)
328
+ * - Maps with canvas rendering (county maps via UsaMap.County)
329
+ * - Data bites (.bite-content)
330
+ * - Data tables (.data-table)
331
+ * - Waffle charts (SVG-based)
332
+ *
333
+ * Use as a play function in any Components/Templates/ story:
334
+ * play: async ({ canvasElement }) => {
335
+ * await assertVisualizationRendered(canvasElement)
336
+ * }
337
+ *
338
+ * @param vizElement The story's canvas element (Storybook's canvasElement)
339
+ */
340
+ export const assertVisualizationRendered = async (vizElement: HTMLElement) => {
341
+ await performAndAssert(
342
+ 'Wait for visualization to render',
343
+ () => {
344
+ const svgCount = vizElement.querySelectorAll('svg').length
345
+ const canvasCount = vizElement.querySelectorAll('canvas').length
346
+ const hasCoveModule = !!vizElement.querySelector('.cdc-open-viz-module')
347
+ const isDataBite = !!vizElement.querySelector('.bite-content')
348
+ const isDataTable = !!vizElement.querySelector('.type-data-table')
349
+ return { svgCount, canvasCount, hasCoveModule, isDataBite, isDataTable }
350
+ },
351
+ async () => {},
352
+ (_before, after) => {
353
+ return (
354
+ ((after.svgCount > 0 || after.canvasCount > 0) && after.hasCoveModule) || after.isDataBite || after.isDataTable
355
+ )
356
+ }
357
+ )
358
+ }
359
+
316
360
  // ============================================================================
317
361
  // DATA EXTRACTION HELPERS
318
362
  // ============================================================================
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import DataTransform from '../DataTransform'
3
+
4
+ describe('DataTransform', () => {
5
+ let transformer: DataTransform
6
+
7
+ beforeEach(() => {
8
+ transformer = new DataTransform()
9
+ })
10
+
11
+ describe('autoStandardize', () => {
12
+ it('returns undefined for empty data', () => {
13
+ expect(transformer.autoStandardize([])).toBeUndefined()
14
+ expect(transformer.autoStandardize(null)).toBeUndefined()
15
+ expect(transformer.autoStandardize(undefined)).toBeUndefined()
16
+ })
17
+
18
+ it('returns undefined for invalid data format', () => {
19
+ expect(transformer.autoStandardize(['string', 'array'])).toBeUndefined()
20
+ expect(transformer.autoStandardize([1, 2, 3])).toBeUndefined()
21
+ })
22
+
23
+ it('returns data unchanged when already in object format', () => {
24
+ const data = [
25
+ { name: 'Alice', value: 10 },
26
+ { name: 'Bob', value: 20 }
27
+ ]
28
+ const result = transformer.autoStandardize(data)
29
+ expect(result).toEqual(data)
30
+ })
31
+
32
+ it('converts array of arrays to array of objects', () => {
33
+ const data = [
34
+ ['name', 'value'],
35
+ ['Alice', 10],
36
+ ['Bob', 20]
37
+ ]
38
+ const result = transformer.autoStandardize(data)
39
+ expect(result).toEqual([
40
+ { name: 'Alice', value: 10 },
41
+ { name: 'Bob', value: 20 }
42
+ ])
43
+ })
44
+
45
+ it('handles array of arrays with multiple columns', () => {
46
+ const data = [
47
+ ['state', 'year', 'population'],
48
+ ['CA', 2020, 39538223],
49
+ ['TX', 2020, 29145505]
50
+ ]
51
+ const result = transformer.autoStandardize(data)
52
+ expect(result).toEqual([
53
+ { state: 'CA', year: 2020, population: 39538223 },
54
+ { state: 'TX', year: 2020, population: 29145505 }
55
+ ])
56
+ })
57
+ })
58
+
59
+ describe('cleanDataPoint', () => {
60
+ it('removes commas from numbers', () => {
61
+ expect(transformer.cleanDataPoint('1,234')).toBe('1234')
62
+ expect(transformer.cleanDataPoint('1,234,567')).toBe('1234567')
63
+ })
64
+
65
+ it('removes dollar signs', () => {
66
+ expect(transformer.cleanDataPoint('$100')).toBe('100')
67
+ expect(transformer.cleanDataPoint('$1,234')).toBe('1234')
68
+ })
69
+
70
+ it('removes percent signs', () => {
71
+ expect(transformer.cleanDataPoint('50%')).toBe('50')
72
+ expect(transformer.cleanDataPoint('99.9%')).toBe('99.9')
73
+ })
74
+
75
+ it('handles combined formatting', () => {
76
+ expect(transformer.cleanDataPoint('$1,234,567')).toBe('1234567')
77
+ })
78
+
79
+ it('handles null and empty values', () => {
80
+ expect(transformer.cleanDataPoint(null)).toBe('')
81
+ expect(transformer.cleanDataPoint('')).toBe('')
82
+ })
83
+
84
+ it('handles non-string values', () => {
85
+ expect(transformer.cleanDataPoint(123)).toBe(123)
86
+ expect(transformer.cleanDataPoint(0)).toBe(0)
87
+ })
88
+
89
+ it('returns plain numbers unchanged', () => {
90
+ expect(transformer.cleanDataPoint('123')).toBe('123')
91
+ expect(transformer.cleanDataPoint('45.67')).toBe('45.67')
92
+ })
93
+ })
94
+
95
+ describe('developerStandardize', () => {
96
+ it('returns empty array when data is falsy', () => {
97
+ expect(transformer.developerStandardize(null, {})).toEqual([])
98
+ expect(transformer.developerStandardize(undefined, {})).toEqual([])
99
+ })
100
+
101
+ it('returns undefined when description is missing', () => {
102
+ const data = [{ a: 1 }]
103
+ expect(transformer.developerStandardize(data, null)).toBeUndefined()
104
+ expect(transformer.developerStandardize(data, undefined)).toBeUndefined()
105
+ })
106
+
107
+ it('returns undefined when description is incomplete', () => {
108
+ const data = [{ a: 1 }]
109
+ expect(transformer.developerStandardize(data, {})).toBeUndefined()
110
+ expect(transformer.developerStandardize(data, { horizontal: true })).toBeUndefined()
111
+ expect(transformer.developerStandardize(data, { series: true })).toBeUndefined()
112
+ })
113
+
114
+ it('handles horizontal data without series', () => {
115
+ const data = [
116
+ { category: 'A', val1: 10, val2: 20 },
117
+ { category: 'B', val1: 30, val2: 40 }
118
+ ]
119
+ const description = { horizontal: true, series: false }
120
+ const result = transformer.developerStandardize(data, description)
121
+ expect(result).toBeDefined()
122
+ expect(Array.isArray(result)).toBe(true)
123
+ })
124
+ })
125
+ })
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { getDateRenderFormat, formatDate } from '../cove/date'
3
+
4
+ const NBSP = '\u00A0'
5
+
6
+ describe('getDateRenderFormat', () => {
7
+ it('replaces space between %b. and %-d with NBSP', () => {
8
+ expect(getDateRenderFormat('%b. %-d %Y')).toBe(`%b.${NBSP}%-d %Y`)
9
+ })
10
+
11
+ it('replaces space between %b and %-d with NBSP', () => {
12
+ expect(getDateRenderFormat('%b %-d %Y')).toBe(`%b${NBSP}%-d %Y`)
13
+ })
14
+
15
+ it('replaces space between %B and %-d (full month name)', () => {
16
+ expect(getDateRenderFormat('%B %-d, %Y')).toBe(`%B${NBSP}%-d, %Y`)
17
+ })
18
+
19
+ it('leaves format unchanged when no month-day space pattern', () => {
20
+ expect(getDateRenderFormat('%Y-%m-%d')).toBe('%Y-%m-%d')
21
+ })
22
+
23
+ it('does not replace existing NBSP (idempotent)', () => {
24
+ const alreadyNbsp = `%b.${NBSP}%-d %Y`
25
+ expect(getDateRenderFormat(alreadyNbsp)).toBe(alreadyNbsp)
26
+ })
27
+
28
+ it('returns undefined for undefined input', () => {
29
+ expect(getDateRenderFormat(undefined)).toBeUndefined()
30
+ })
31
+
32
+ it('returns empty string for empty string input', () => {
33
+ expect(getDateRenderFormat('')).toBe('')
34
+ })
35
+ })
36
+
37
+ describe('formatDate', () => {
38
+ it('renders date with NBSP when format has space between month and day', () => {
39
+ const date = new Date(2025, 0, 15) // Jan 15, 2025
40
+ const result = formatDate('%b. %-d %Y', date)
41
+ expect(result).toContain(NBSP)
42
+ expect(result).toBe(`Jan.${NBSP}15 2025`)
43
+ })
44
+
45
+ it('strips trailing period from "May." when using %b. format', () => {
46
+ const date = new Date(2025, 4, 15) // May 15, 2025
47
+ const result = formatDate('%b. %-d, %Y', date)
48
+ expect(result).not.toContain('May.')
49
+ expect(result).toContain('May')
50
+ })
51
+
52
+ it('leaves "May" alone when format does not use %b.', () => {
53
+ const date = new Date(2025, 4, 15) // May 15, 2025
54
+ const result = formatDate('%b %-d, %Y', date)
55
+ expect(result).toContain('May')
56
+ expect(result).not.toContain('May.')
57
+ })
58
+
59
+ it('does not strip period from other months when using %b. format', () => {
60
+ const date = new Date(2025, 0, 15) // Jan 15, 2025
61
+ const result = formatDate('%b. %-d, %Y', date)
62
+ expect(result).toContain('Jan.')
63
+ })
64
+ })