@svgrid/grid 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +33 -0
- package/README.md +39 -0
- package/dist/FlexRender.svelte +96 -0
- package/dist/FlexRender.svelte.d.ts +49 -0
- package/dist/SvGrid.svelte +8742 -0
- package/dist/SvGrid.svelte.d.ts +381 -0
- package/dist/SvGridChart.svelte +653 -0
- package/dist/SvGridChart.svelte.d.ts +25 -0
- package/dist/SvGridChart.test.d.ts +1 -0
- package/dist/SvGridChart.test.js +62 -0
- package/dist/SvGridDropdown.svelte +675 -0
- package/dist/SvGridDropdown.svelte.d.ts +40 -0
- package/dist/a11y.contract.test.d.ts +1 -0
- package/dist/a11y.contract.test.js +39 -0
- package/dist/a11y.d.ts +38 -0
- package/dist/a11y.js +37 -0
- package/dist/a11y.test.d.ts +1 -0
- package/dist/a11y.test.js +44 -0
- package/dist/cell-formatting.d.ts +17 -0
- package/dist/cell-formatting.js +141 -0
- package/dist/cell-formatting.test.d.ts +1 -0
- package/dist/cell-formatting.test.js +234 -0
- package/dist/chart-export.d.ts +14 -0
- package/dist/chart-export.js +112 -0
- package/dist/chart.d.ts +226 -0
- package/dist/chart.js +732 -0
- package/dist/chart.test.d.ts +1 -0
- package/dist/chart.test.js +289 -0
- package/dist/collaboration.d.ts +74 -0
- package/dist/collaboration.js +98 -0
- package/dist/collaboration.test.d.ts +1 -0
- package/dist/collaboration.test.js +65 -0
- package/dist/conditional-formatting.d.ts +91 -0
- package/dist/conditional-formatting.js +170 -0
- package/dist/conditional-formatting.test.d.ts +1 -0
- package/dist/conditional-formatting.test.js +87 -0
- package/dist/core.coverage.test.d.ts +1 -0
- package/dist/core.coverage.test.js +186 -0
- package/dist/core.d.ts +370 -0
- package/dist/core.js +568 -0
- package/dist/core.performance.test.d.ts +1 -0
- package/dist/core.performance.test.js +29 -0
- package/dist/createGrid.svelte.d.ts +6 -0
- package/dist/createGrid.svelte.js +17 -0
- package/dist/createGrid.test.d.ts +1 -0
- package/dist/createGrid.test.js +9 -0
- package/dist/createGridState.svelte.d.ts +3 -0
- package/dist/createGridState.svelte.js +13 -0
- package/dist/editors/cell-editors.d.ts +23 -0
- package/dist/editors/cell-editors.js +97 -0
- package/dist/editors/cell-editors.test.d.ts +1 -0
- package/dist/editors/cell-editors.test.js +75 -0
- package/dist/fill-patterns.d.ts +30 -0
- package/dist/fill-patterns.js +207 -0
- package/dist/fill-patterns.test.d.ts +1 -0
- package/dist/fill-patterns.test.js +81 -0
- package/dist/filtering/excel-filters.d.ts +26 -0
- package/dist/filtering/excel-filters.js +64 -0
- package/dist/filtering/excel-filters.test.d.ts +1 -0
- package/dist/filtering/excel-filters.test.js +116 -0
- package/dist/filtering/locale-filter.test.d.ts +13 -0
- package/dist/filtering/locale-filter.test.js +189 -0
- package/dist/flex-render.d.ts +1 -0
- package/dist/flex-render.js +2 -0
- package/dist/flex-render.test.d.ts +1 -0
- package/dist/flex-render.test.js +142 -0
- package/dist/group-aggregate.test.d.ts +1 -0
- package/dist/group-aggregate.test.js +32 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +27 -0
- package/dist/keyboard.d.ts +8 -0
- package/dist/keyboard.js +91 -0
- package/dist/keyboard.test.d.ts +1 -0
- package/dist/keyboard.test.js +27 -0
- package/dist/merge-objects.d.ts +9 -0
- package/dist/merge-objects.js +28 -0
- package/dist/named-views.d.ts +42 -0
- package/dist/named-views.js +80 -0
- package/dist/named-views.test.d.ts +1 -0
- package/dist/named-views.test.js +57 -0
- package/dist/new-features.test.d.ts +1 -0
- package/dist/new-features.test.js +183 -0
- package/dist/render-component.d.ts +13 -0
- package/dist/render-component.js +14 -0
- package/dist/render-component.test.d.ts +1 -0
- package/dist/render-component.test.js +41 -0
- package/dist/server-data-source.d.ts +76 -0
- package/dist/server-data-source.js +83 -0
- package/dist/server-data-source.test.d.ts +1 -0
- package/dist/server-data-source.test.js +81 -0
- package/dist/sparkline.d.ts +56 -0
- package/dist/sparkline.js +97 -0
- package/dist/sparkline.test.d.ts +1 -0
- package/dist/sparkline.test.js +52 -0
- package/dist/static-functions.d.ts +1 -0
- package/dist/static-functions.js +1 -0
- package/dist/subscribe.d.ts +9 -0
- package/dist/subscribe.js +31 -0
- package/dist/subscribe.test.d.ts +1 -0
- package/dist/subscribe.test.js +93 -0
- package/dist/sv-grid-scrollbar.d.ts +1 -0
- package/dist/sv-grid-scrollbar.js +310 -0
- package/dist/svgrid-wrapper.types.d.ts +341 -0
- package/dist/svgrid-wrapper.types.js +1 -0
- package/dist/svgrid.api-extensions.test.d.ts +14 -0
- package/dist/svgrid.api-extensions.test.js +334 -0
- package/dist/svgrid.api.test.d.ts +15 -0
- package/dist/svgrid.api.test.js +169 -0
- package/dist/svgrid.behavior.test.d.ts +15 -0
- package/dist/svgrid.behavior.test.js +605 -0
- package/dist/svgrid.column-reorder.test.d.ts +15 -0
- package/dist/svgrid.column-reorder.test.js +224 -0
- package/dist/svgrid.features.test.d.ts +1 -0
- package/dist/svgrid.features.test.js +129 -0
- package/dist/svgrid.interaction.test.d.ts +9 -0
- package/dist/svgrid.interaction.test.js +318 -0
- package/dist/svgrid.locale-filtering.test.d.ts +15 -0
- package/dist/svgrid.locale-filtering.test.js +261 -0
- package/dist/svgrid.new-features.wrapper.test.d.ts +1 -0
- package/dist/svgrid.new-features.wrapper.test.js +136 -0
- package/dist/svgrid.row-pinning.test.d.ts +14 -0
- package/dist/svgrid.row-pinning.test.js +250 -0
- package/dist/svgrid.set-filter.test.d.ts +17 -0
- package/dist/svgrid.set-filter.test.js +268 -0
- package/dist/svgrid.wrapper.test.d.ts +1 -0
- package/dist/svgrid.wrapper.test.js +33 -0
- package/dist/test-setup.d.ts +1 -0
- package/dist/test-setup.js +29 -0
- package/dist/transaction.test.d.ts +1 -0
- package/dist/transaction.test.js +89 -0
- package/dist/virtualization/column-virtualizer.d.ts +20 -0
- package/dist/virtualization/column-virtualizer.js +19 -0
- package/dist/virtualization/column-virtualizer.test.d.ts +1 -0
- package/dist/virtualization/column-virtualizer.test.js +25 -0
- package/dist/virtualization/svelte-virtualizer.svelte.d.ts +11 -0
- package/dist/virtualization/svelte-virtualizer.svelte.js +20 -0
- package/dist/virtualization/types.d.ts +28 -0
- package/dist/virtualization/types.js +1 -0
- package/dist/virtualization/virtualizer.d.ts +13 -0
- package/dist/virtualization/virtualizer.js +232 -0
- package/dist/virtualization/virtualizer.test.d.ts +1 -0
- package/dist/virtualization/virtualizer.test.js +41 -0
- package/package.json +69 -0
- package/src/FlexRender.svelte +96 -0
- package/src/SvGrid.svelte +8742 -0
- package/src/SvGridChart.svelte +653 -0
- package/src/SvGridChart.test.ts +69 -0
- package/src/SvGridDropdown.svelte +675 -0
- package/src/a11y.contract.test.ts +49 -0
- package/src/a11y.test.ts +59 -0
- package/src/a11y.ts +59 -0
- package/src/cell-formatting.test.ts +273 -0
- package/src/cell-formatting.ts +169 -0
- package/src/chart-export.ts +144 -0
- package/src/chart.test.ts +311 -0
- package/src/chart.ts +934 -0
- package/src/collaboration.test.ts +74 -0
- package/src/collaboration.ts +161 -0
- package/src/conditional-formatting.test.ts +107 -0
- package/src/conditional-formatting.ts +239 -0
- package/src/core.coverage.test.ts +230 -0
- package/src/core.performance.test.ts +30 -0
- package/src/core.ts +991 -0
- package/src/createGrid.svelte.ts +42 -0
- package/src/createGrid.test.ts +10 -0
- package/src/createGridState.svelte.ts +17 -0
- package/src/editors/cell-editors.test.ts +80 -0
- package/src/editors/cell-editors.ts +127 -0
- package/src/fill-patterns.test.ts +93 -0
- package/src/fill-patterns.ts +229 -0
- package/src/filtering/excel-filters.test.ts +131 -0
- package/src/filtering/excel-filters.ts +101 -0
- package/src/filtering/locale-filter.test.ts +220 -0
- package/src/flex-render.test.ts +143 -0
- package/src/flex-render.ts +3 -0
- package/src/group-aggregate.test.ts +48 -0
- package/src/index.ts +159 -0
- package/src/keyboard.test.ts +59 -0
- package/src/keyboard.ts +97 -0
- package/src/merge-objects.ts +48 -0
- package/src/named-views.test.ts +66 -0
- package/src/named-views.ts +120 -0
- package/src/new-features.test.ts +217 -0
- package/src/render-component.test.ts +51 -0
- package/src/render-component.ts +28 -0
- package/src/server-data-source.test.ts +88 -0
- package/src/server-data-source.ts +163 -0
- package/src/sparkline.test.ts +59 -0
- package/src/sparkline.ts +164 -0
- package/src/static-functions.ts +11 -0
- package/src/subscribe.test.ts +103 -0
- package/src/subscribe.ts +38 -0
- package/src/sv-grid-scrollbar.ts +347 -0
- package/src/svgrid-wrapper.types.ts +382 -0
- package/src/svgrid.api-extensions.test.ts +362 -0
- package/src/svgrid.api.test.ts +192 -0
- package/src/svgrid.behavior.test.ts +657 -0
- package/src/svgrid.column-reorder.test.ts +234 -0
- package/src/svgrid.features.test.ts +157 -0
- package/src/svgrid.interaction.test.ts +355 -0
- package/src/svgrid.locale-filtering.test.ts +259 -0
- package/src/svgrid.new-features.wrapper.test.ts +164 -0
- package/src/svgrid.row-pinning.test.ts +266 -0
- package/src/svgrid.set-filter.test.ts +270 -0
- package/src/svgrid.wrapper.test.ts +35 -0
- package/src/test-setup.ts +37 -0
- package/src/transaction.test.ts +100 -0
- package/src/virtualization/column-virtualizer.test.ts +27 -0
- package/src/virtualization/column-virtualizer.ts +30 -0
- package/src/virtualization/svelte-virtualizer.svelte.ts +24 -0
- package/src/virtualization/types.ts +30 -0
- package/src/virtualization/virtualizer.test.ts +47 -0
- package/src/virtualization/virtualizer.ts +270 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for applyExcelFilter. Every operator + edge case.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { applyExcelFilter, type ExcelFilter } from './excel-filters'
|
|
6
|
+
|
|
7
|
+
const make = (
|
|
8
|
+
operator: ExcelFilter['operator'],
|
|
9
|
+
value?: unknown,
|
|
10
|
+
valueTo?: unknown,
|
|
11
|
+
): ExcelFilter => ({
|
|
12
|
+
id: 'col',
|
|
13
|
+
operator,
|
|
14
|
+
value,
|
|
15
|
+
valueTo,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('applyExcelFilter - basic operators (smoke regression)', () => {
|
|
19
|
+
it('supports text operators (contains, startsWith, equals)', () => {
|
|
20
|
+
expect(applyExcelFilter('alphabet', make('contains', 'pha'))).toBe(true)
|
|
21
|
+
expect(applyExcelFilter('alphabet', make('startsWith', 'alp'))).toBe(true)
|
|
22
|
+
expect(applyExcelFilter('alphabet', make('equals', 'alphabet'))).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('supports numeric operators (greaterThan, between)', () => {
|
|
26
|
+
expect(applyExcelFilter(15, make('greaterThan', 10))).toBe(true)
|
|
27
|
+
expect(applyExcelFilter(15, make('between', 10, 20))).toBe(true)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('supports blank checks', () => {
|
|
31
|
+
expect(applyExcelFilter('', make('isBlank'))).toBe(true)
|
|
32
|
+
expect(applyExcelFilter('x', make('isBlank'))).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('applyExcelFilter - contains', () => {
|
|
37
|
+
it('matches case-insensitive substrings', () => {
|
|
38
|
+
expect(applyExcelFilter('Hello World', make('contains', 'world'))).toBe(true)
|
|
39
|
+
expect(applyExcelFilter('Hello World', make('contains', 'World'))).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
it('returns false when substring absent', () => {
|
|
42
|
+
expect(applyExcelFilter('Hello', make('contains', 'xyz'))).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
it('treats empty filter value as universal match', () => {
|
|
45
|
+
expect(applyExcelFilter('anything', make('contains', ''))).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
it('coerces null cell to empty string', () => {
|
|
48
|
+
expect(applyExcelFilter(null, make('contains', 'x'))).toBe(false)
|
|
49
|
+
expect(applyExcelFilter(null, make('contains', ''))).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('applyExcelFilter - equals', () => {
|
|
54
|
+
it('matches case-insensitively', () => {
|
|
55
|
+
expect(applyExcelFilter('Boston', make('equals', 'boston'))).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
it('rejects partial matches', () => {
|
|
58
|
+
expect(applyExcelFilter('Boston', make('equals', 'bost'))).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
it('treats null as empty', () => {
|
|
61
|
+
expect(applyExcelFilter(null, make('equals', ''))).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('applyExcelFilter - startsWith', () => {
|
|
66
|
+
it('matches a prefix case-insensitively', () => {
|
|
67
|
+
expect(applyExcelFilter('Brooklyn', make('startsWith', 'brook'))).toBe(true)
|
|
68
|
+
expect(applyExcelFilter('Brooklyn', make('startsWith', 'lyn'))).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('applyExcelFilter - greaterThan', () => {
|
|
73
|
+
it('compares numerically when both sides parse as numbers', () => {
|
|
74
|
+
expect(applyExcelFilter(100, make('greaterThan', 99))).toBe(true)
|
|
75
|
+
expect(applyExcelFilter('100', make('greaterThan', 50))).toBe(true)
|
|
76
|
+
expect(applyExcelFilter(50, make('greaterThan', 50))).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
it('falls back to string comparison when either side is non-numeric', () => {
|
|
79
|
+
expect(applyExcelFilter('banana', make('greaterThan', 'apple'))).toBe(true)
|
|
80
|
+
expect(applyExcelFilter('apple', make('greaterThan', 'banana'))).toBe(false)
|
|
81
|
+
})
|
|
82
|
+
it('treats null on either side as empty string in the fallback', () => {
|
|
83
|
+
expect(applyExcelFilter(null, make('greaterThan', 'a'))).toBe(false)
|
|
84
|
+
expect(applyExcelFilter('a', make('greaterThan', null))).toBe(true)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('applyExcelFilter - lessThan', () => {
|
|
89
|
+
it('compares numerically when both sides parse', () => {
|
|
90
|
+
expect(applyExcelFilter(10, make('lessThan', 20))).toBe(true)
|
|
91
|
+
expect(applyExcelFilter(20, make('lessThan', 10))).toBe(false)
|
|
92
|
+
})
|
|
93
|
+
it('falls back to string comparison', () => {
|
|
94
|
+
expect(applyExcelFilter('apple', make('lessThan', 'banana'))).toBe(true)
|
|
95
|
+
})
|
|
96
|
+
it('treats nulls as empty strings in fallback', () => {
|
|
97
|
+
expect(applyExcelFilter(null, make('lessThan', 'a'))).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('applyExcelFilter - between', () => {
|
|
102
|
+
it('includes both endpoints', () => {
|
|
103
|
+
expect(applyExcelFilter(5, make('between', 1, 10))).toBe(true)
|
|
104
|
+
expect(applyExcelFilter(1, make('between', 1, 10))).toBe(true)
|
|
105
|
+
expect(applyExcelFilter(10, make('between', 1, 10))).toBe(true)
|
|
106
|
+
})
|
|
107
|
+
it('excludes values outside the range', () => {
|
|
108
|
+
expect(applyExcelFilter(0, make('between', 1, 10))).toBe(false)
|
|
109
|
+
expect(applyExcelFilter(11, make('between', 1, 10))).toBe(false)
|
|
110
|
+
})
|
|
111
|
+
it('treats undefined endpoints as 0', () => {
|
|
112
|
+
expect(applyExcelFilter(0, make('between'))).toBe(true)
|
|
113
|
+
expect(applyExcelFilter(1, make('between'))).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
it('treats null cell as 0', () => {
|
|
116
|
+
expect(applyExcelFilter(null, make('between', -1, 1))).toBe(true)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('applyExcelFilter - isBlank', () => {
|
|
121
|
+
it('matches null, undefined, and whitespace-only strings', () => {
|
|
122
|
+
expect(applyExcelFilter(null, make('isBlank'))).toBe(true)
|
|
123
|
+
expect(applyExcelFilter(undefined, make('isBlank'))).toBe(true)
|
|
124
|
+
expect(applyExcelFilter('', make('isBlank'))).toBe(true)
|
|
125
|
+
expect(applyExcelFilter(' ', make('isBlank'))).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
it('rejects non-blank content', () => {
|
|
128
|
+
expect(applyExcelFilter('x', make('isBlank'))).toBe(false)
|
|
129
|
+
expect(applyExcelFilter(0, make('isBlank'))).toBe(false)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
export type ExcelFilterOperator =
|
|
2
|
+
| 'contains'
|
|
3
|
+
| 'equals'
|
|
4
|
+
| 'startsWith'
|
|
5
|
+
| 'greaterThan'
|
|
6
|
+
| 'lessThan'
|
|
7
|
+
| 'between'
|
|
8
|
+
| 'isBlank'
|
|
9
|
+
|
|
10
|
+
export type ExcelFilter = {
|
|
11
|
+
id: string
|
|
12
|
+
operator: ExcelFilterOperator
|
|
13
|
+
value?: unknown
|
|
14
|
+
valueTo?: unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ExcelFilterOptions = {
|
|
18
|
+
/**
|
|
19
|
+
* BCP-47 locale tag (or fallback list) for accent- and case-
|
|
20
|
+
* insensitive text comparison. When set, "café", "Café" and "CAFÉ"
|
|
21
|
+
* all match "cafe" without strain.
|
|
22
|
+
*
|
|
23
|
+
* Implementation: NFD-decompose both sides, strip combining marks
|
|
24
|
+
* (diacritics), then locale-aware lowercase. This is the standard
|
|
25
|
+
* "lowercase + asciifolding" pipeline used by Elasticsearch, Postgres
|
|
26
|
+
* (with `unaccent`), and Lucene. Cheap, deterministic, no Collator
|
|
27
|
+
* round-trip.
|
|
28
|
+
*/
|
|
29
|
+
locale?: string | ReadonlyArray<string>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// U+0300..U+036F is the Combining Diacritical Marks block. After NFD
|
|
33
|
+
// decomposition accented characters become "base char + combining mark";
|
|
34
|
+
// stripping the marks leaves the bare ASCII base.
|
|
35
|
+
const DIACRITIC_RE = /[̀-ͯ]/g
|
|
36
|
+
|
|
37
|
+
/** NFD-decompose, strip combining marks (diacritics), then locale-aware
|
|
38
|
+
* lowercase. The locale-aware lowercasing handles Turkish dotted-I /
|
|
39
|
+
* dotless-i correctly when the consumer threads `"tr"` through. */
|
|
40
|
+
export function normalizeForFilter(
|
|
41
|
+
s: string,
|
|
42
|
+
locale?: string | ReadonlyArray<string>,
|
|
43
|
+
): string {
|
|
44
|
+
if (!s) return ''
|
|
45
|
+
const stripped = s.normalize('NFD').replace(DIACRITIC_RE, '')
|
|
46
|
+
return locale
|
|
47
|
+
? stripped.toLocaleLowerCase(locale as string | string[])
|
|
48
|
+
: stripped.toLowerCase()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function applyExcelFilter(
|
|
52
|
+
cellValue: unknown,
|
|
53
|
+
filter: ExcelFilter,
|
|
54
|
+
options?: ExcelFilterOptions,
|
|
55
|
+
) {
|
|
56
|
+
const text = String(cellValue ?? '')
|
|
57
|
+
const normalizedText = normalizeForFilter(text, options?.locale)
|
|
58
|
+
const normalizedValue = normalizeForFilter(String(filter.value ?? ''), options?.locale)
|
|
59
|
+
switch (filter.operator) {
|
|
60
|
+
case 'contains':
|
|
61
|
+
return normalizedText.includes(normalizedValue)
|
|
62
|
+
case 'equals':
|
|
63
|
+
return normalizedText === normalizedValue
|
|
64
|
+
case 'startsWith':
|
|
65
|
+
return normalizedText.startsWith(normalizedValue)
|
|
66
|
+
case 'greaterThan': {
|
|
67
|
+
const a = Number(cellValue)
|
|
68
|
+
const b = Number(filter.value)
|
|
69
|
+
if (Number.isFinite(a) && Number.isFinite(b)) return a > b
|
|
70
|
+
return String(cellValue ?? '') > String(filter.value ?? '')
|
|
71
|
+
}
|
|
72
|
+
case 'lessThan': {
|
|
73
|
+
const a = Number(cellValue)
|
|
74
|
+
const b = Number(filter.value)
|
|
75
|
+
if (Number.isFinite(a) && Number.isFinite(b)) return a < b
|
|
76
|
+
return String(cellValue ?? '') < String(filter.value ?? '')
|
|
77
|
+
}
|
|
78
|
+
case 'between': {
|
|
79
|
+
// Two paths: numeric (coerce both endpoints with the historical
|
|
80
|
+
// `?? 0` fallback) and string (used when EITHER endpoint is a
|
|
81
|
+
// non-numeric string like an ISO date). Numeric wins when its
|
|
82
|
+
// operands are finite so callers passing numeric ages, prices, etc.
|
|
83
|
+
// see the historical inclusive-range behaviour.
|
|
84
|
+
const lo = filter.value == null ? 0 : Number(filter.value)
|
|
85
|
+
const hi = filter.valueTo == null ? 0 : Number(filter.valueTo)
|
|
86
|
+
const a = Number(cellValue ?? 0)
|
|
87
|
+
if (Number.isFinite(a) && Number.isFinite(lo) && Number.isFinite(hi)) {
|
|
88
|
+
return a >= lo && a <= hi
|
|
89
|
+
}
|
|
90
|
+
// Either endpoint is non-numeric (ISO date string, etc). Compare
|
|
91
|
+
// lexicographically - YYYY-MM-DD orders chronologically so the
|
|
92
|
+
// result matches user intent.
|
|
93
|
+
const s = String(cellValue ?? '')
|
|
94
|
+
const sl = String(filter.value ?? '')
|
|
95
|
+
const sh = String(filter.valueTo ?? '')
|
|
96
|
+
return s >= sl && s <= sh
|
|
97
|
+
}
|
|
98
|
+
case 'isBlank':
|
|
99
|
+
return text.trim().length === 0
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure unit tests for the locale-aware text normalization + Excel
|
|
3
|
+
* filter pipeline. No DOM, no mounted grid - just the two exported
|
|
4
|
+
* helpers `normalizeForFilter` and `applyExcelFilter`.
|
|
5
|
+
*
|
|
6
|
+
* Covers:
|
|
7
|
+
* - Diacritic stripping (Café → cafe)
|
|
8
|
+
* - Composed / decomposed Unicode round-trip
|
|
9
|
+
* - Locale-specific lowercasing (Turkish dotted-I)
|
|
10
|
+
* - All Excel operators × locale option × accented inputs
|
|
11
|
+
* - Edge cases: empty strings, null/undefined, numeric mixed with text
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from 'vitest'
|
|
15
|
+
import {
|
|
16
|
+
applyExcelFilter,
|
|
17
|
+
normalizeForFilter,
|
|
18
|
+
} from './excel-filters'
|
|
19
|
+
|
|
20
|
+
describe('normalizeForFilter - diacritic stripping', () => {
|
|
21
|
+
it('strips Latin accents (NFD path)', () => {
|
|
22
|
+
expect(normalizeForFilter('Café')).toBe('cafe')
|
|
23
|
+
expect(normalizeForFilter('CAFÉ')).toBe('cafe')
|
|
24
|
+
expect(normalizeForFilter('München')).toBe('munchen')
|
|
25
|
+
expect(normalizeForFilter('São Paulo')).toBe('sao paulo')
|
|
26
|
+
expect(normalizeForFilter('Tōkyō')).toBe('tokyo')
|
|
27
|
+
expect(normalizeForFilter('Genève')).toBe('geneve')
|
|
28
|
+
expect(normalizeForFilter('Québec')).toBe('quebec')
|
|
29
|
+
expect(normalizeForFilter('Málaga')).toBe('malaga')
|
|
30
|
+
expect(normalizeForFilter('Brasília')).toBe('brasilia')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('handles already-decomposed input (NFD-stable)', () => {
|
|
34
|
+
// "Café" composed (4 code points) vs decomposed (5 code points).
|
|
35
|
+
const composed = 'Café'.normalize('NFC')
|
|
36
|
+
const decomposed = 'Café'.normalize('NFD')
|
|
37
|
+
expect(normalizeForFilter(composed)).toBe('cafe')
|
|
38
|
+
expect(normalizeForFilter(decomposed)).toBe('cafe')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('preserves base ASCII unchanged', () => {
|
|
42
|
+
expect(normalizeForFilter('Hello World')).toBe('hello world')
|
|
43
|
+
expect(normalizeForFilter('ABC-123')).toBe('abc-123')
|
|
44
|
+
expect(normalizeForFilter('email@example.com')).toBe('email@example.com')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns empty string for empty / falsy input', () => {
|
|
48
|
+
expect(normalizeForFilter('')).toBe('')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('keeps non-Latin alphabets intact', () => {
|
|
52
|
+
// The diacritic strip only touches the Combining Diacritical Marks
|
|
53
|
+
// block (U+0300..U+036F). CJK / Cyrillic / Arabic stay as-is.
|
|
54
|
+
expect(normalizeForFilter('東京')).toBe('東京')
|
|
55
|
+
expect(normalizeForFilter('Москва')).toBe('москва')
|
|
56
|
+
expect(normalizeForFilter('القاهرة')).toBe('القاهرة')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('normalizeForFilter - locale-aware lowercase', () => {
|
|
61
|
+
it('default (no locale) makes English "istanbul" match dotted Turkish "İstanbul"', () => {
|
|
62
|
+
// No locale: NFD decomposes İ → I + combining dot above; the dot is
|
|
63
|
+
// stripped; remaining 'I' becomes 'i' under default lowercase.
|
|
64
|
+
expect(normalizeForFilter('İstanbul')).toBe('istanbul')
|
|
65
|
+
expect(normalizeForFilter('istanbul')).toBe('istanbul')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('Turkish locale honors dotted-I / dotless-i asymmetry', () => {
|
|
69
|
+
// With "tr-TR" the bare 'I' (post-dot-strip) lowercases to dotless
|
|
70
|
+
// 'ı'. This is intentional Turkish behavior and is why we expose a
|
|
71
|
+
// locale prop: consumers who explicitly want Turkish casing get it.
|
|
72
|
+
// English-typed "istanbul" then no longer matches Turkish "İstanbul"
|
|
73
|
+
// - locale-pickers should make that trade-off knowingly.
|
|
74
|
+
expect(normalizeForFilter('İSTANBUL', 'tr-TR')).toBe('ıstanbul')
|
|
75
|
+
expect(normalizeForFilter('I', 'tr-TR')).toBe('ı')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('default (no locale) behaves like toLowerCase()', () => {
|
|
79
|
+
expect(normalizeForFilter('HELLO')).toBe('hello')
|
|
80
|
+
expect(normalizeForFilter('CafÉ')).toBe('cafe') // É
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('accepts an array of fallback locales', () => {
|
|
84
|
+
expect(normalizeForFilter('Café', ['fr-CA', 'en-US'])).toBe('cafe')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('Greek / Cyrillic / German all fold cleanly', () => {
|
|
88
|
+
// Greek: stripped tones (oxia)
|
|
89
|
+
expect(normalizeForFilter('Αθήνα')).toBe('αθηνα')
|
|
90
|
+
// German: umlauts strip
|
|
91
|
+
expect(normalizeForFilter('Düsseldorf')).toBe('dusseldorf')
|
|
92
|
+
// Cyrillic: no diacritics, just locale-aware lowercase
|
|
93
|
+
expect(normalizeForFilter('Москва', 'ru-RU')).toBe('москва')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('applyExcelFilter - contains (locale-aware)', () => {
|
|
98
|
+
it('matches case + accent insensitively without a locale', () => {
|
|
99
|
+
const ok = applyExcelFilter('Café Genève', {
|
|
100
|
+
id: 'name', operator: 'contains', value: 'cafe geneve',
|
|
101
|
+
})
|
|
102
|
+
expect(ok).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('matches with a German locale', () => {
|
|
106
|
+
const ok = applyExcelFilter('München', {
|
|
107
|
+
id: 'city', operator: 'contains', value: 'munch',
|
|
108
|
+
}, { locale: 'de-DE' })
|
|
109
|
+
expect(ok).toBe(true)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('matches with a French locale', () => {
|
|
113
|
+
const ok = applyExcelFilter('Montréal', {
|
|
114
|
+
id: 'city', operator: 'contains', value: 'montreal',
|
|
115
|
+
}, { locale: 'fr-CA' })
|
|
116
|
+
expect(ok).toBe(true)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('matches with a Portuguese locale', () => {
|
|
120
|
+
const ok = applyExcelFilter('São Paulo', {
|
|
121
|
+
id: 'city', operator: 'contains', value: 'sao paulo',
|
|
122
|
+
}, { locale: 'pt-BR' })
|
|
123
|
+
expect(ok).toBe(true)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('rejects non-matching queries even with locale', () => {
|
|
127
|
+
const ok = applyExcelFilter('München', {
|
|
128
|
+
id: 'city', operator: 'contains', value: 'berlin',
|
|
129
|
+
}, { locale: 'de-DE' })
|
|
130
|
+
expect(ok).toBe(false)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('empty needle matches every cell (includes "")', () => {
|
|
134
|
+
expect(applyExcelFilter('München', {
|
|
135
|
+
id: 'city', operator: 'contains', value: '',
|
|
136
|
+
})).toBe(true)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('applyExcelFilter - equals (locale-aware)', () => {
|
|
141
|
+
it('case + accent insensitive equals', () => {
|
|
142
|
+
expect(applyExcelFilter('Café', { id: 'x', operator: 'equals', value: 'CAFE' })).toBe(true)
|
|
143
|
+
expect(applyExcelFilter('Café', { id: 'x', operator: 'equals', value: 'cafe' })).toBe(true)
|
|
144
|
+
expect(applyExcelFilter('Café', { id: 'x', operator: 'equals', value: 'Café' })).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('rejects when normalized strings differ', () => {
|
|
148
|
+
expect(applyExcelFilter('Café', { id: 'x', operator: 'equals', value: 'cafes' })).toBe(false)
|
|
149
|
+
expect(applyExcelFilter('Café', { id: 'x', operator: 'equals', value: 'café!' })).toBe(false)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('applyExcelFilter - startsWith (locale-aware)', () => {
|
|
154
|
+
it('matches stripped prefix', () => {
|
|
155
|
+
expect(applyExcelFilter('München-Schwabing', {
|
|
156
|
+
id: 'x', operator: 'startsWith', value: 'munchen',
|
|
157
|
+
})).toBe(true)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('rejects mid-string match', () => {
|
|
161
|
+
expect(applyExcelFilter('Großmünchen', {
|
|
162
|
+
id: 'x', operator: 'startsWith', value: 'munchen',
|
|
163
|
+
})).toBe(false)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('applyExcelFilter - numeric operators (locale-independent)', () => {
|
|
168
|
+
it('greaterThan / lessThan / between still work', () => {
|
|
169
|
+
expect(applyExcelFilter(150, { id: 'price', operator: 'greaterThan', value: 100 })).toBe(true)
|
|
170
|
+
expect(applyExcelFilter(150, { id: 'price', operator: 'lessThan', value: 200 })).toBe(true)
|
|
171
|
+
expect(applyExcelFilter(150, {
|
|
172
|
+
id: 'price', operator: 'between', value: 100, valueTo: 200,
|
|
173
|
+
})).toBe(true)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('between rejects out-of-range', () => {
|
|
177
|
+
expect(applyExcelFilter(50, {
|
|
178
|
+
id: 'price', operator: 'between', value: 100, valueTo: 200,
|
|
179
|
+
})).toBe(false)
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('applyExcelFilter - isBlank', () => {
|
|
184
|
+
it('treats empty / whitespace-only as blank', () => {
|
|
185
|
+
expect(applyExcelFilter('', { id: 'x', operator: 'isBlank' })).toBe(true)
|
|
186
|
+
expect(applyExcelFilter(' ', { id: 'x', operator: 'isBlank' })).toBe(true)
|
|
187
|
+
expect(applyExcelFilter(null, { id: 'x', operator: 'isBlank' })).toBe(true)
|
|
188
|
+
expect(applyExcelFilter(undefined, { id: 'x', operator: 'isBlank' })).toBe(true)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('rejects non-blank cells', () => {
|
|
192
|
+
expect(applyExcelFilter('Café', { id: 'x', operator: 'isBlank' })).toBe(false)
|
|
193
|
+
expect(applyExcelFilter(' x ', { id: 'x', operator: 'isBlank' })).toBe(false)
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe('applyExcelFilter - regression: pre-locale behavior preserved', () => {
|
|
198
|
+
// Before locale support, ASCII case-insensitive matching was the rule.
|
|
199
|
+
// Verify nothing regressed for the common path.
|
|
200
|
+
it('plain ASCII contains', () => {
|
|
201
|
+
expect(applyExcelFilter('Ada Lovelace', {
|
|
202
|
+
id: 'name', operator: 'contains', value: 'lovelace',
|
|
203
|
+
})).toBe(true)
|
|
204
|
+
expect(applyExcelFilter('Ada Lovelace', {
|
|
205
|
+
id: 'name', operator: 'contains', value: 'LOVELACE',
|
|
206
|
+
})).toBe(true)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('plain ASCII equals', () => {
|
|
210
|
+
expect(applyExcelFilter('active', {
|
|
211
|
+
id: 'status', operator: 'equals', value: 'ACTIVE',
|
|
212
|
+
})).toBe(true)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('plain ASCII startsWith', () => {
|
|
216
|
+
expect(applyExcelFilter('Linus Torvalds', {
|
|
217
|
+
id: 'name', operator: 'startsWith', value: 'linus',
|
|
218
|
+
})).toBe(true)
|
|
219
|
+
})
|
|
220
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlexRender tests. The component routes one of {content+context, cell,
|
|
3
|
+
* header, footer} into the right render path: string, RenderComponentConfig,
|
|
4
|
+
* RenderSnippetConfig, or raw value.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'vitest'
|
|
7
|
+
import { mount, unmount } from 'svelte'
|
|
8
|
+
import FlexRender from './FlexRender.svelte'
|
|
9
|
+
import { RenderComponentConfig, RenderSnippetConfig } from './render-component'
|
|
10
|
+
|
|
11
|
+
function mountFlex(props: any) {
|
|
12
|
+
const target = document.createElement('div')
|
|
13
|
+
document.body.appendChild(target)
|
|
14
|
+
const app = mount(FlexRender, { target, props })
|
|
15
|
+
return {
|
|
16
|
+
target,
|
|
17
|
+
destroy: () => {
|
|
18
|
+
unmount(app)
|
|
19
|
+
target.remove()
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('FlexRender', () => {
|
|
25
|
+
it('renders a plain string content directly', () => {
|
|
26
|
+
const { target, destroy } = mountFlex({
|
|
27
|
+
content: 'Hello header',
|
|
28
|
+
context: { column: {}, table: {} } as any,
|
|
29
|
+
})
|
|
30
|
+
try {
|
|
31
|
+
expect(target.textContent).toContain('Hello header')
|
|
32
|
+
} finally {
|
|
33
|
+
destroy()
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('calls a function content with the context and renders the result', () => {
|
|
38
|
+
const fn = (ctx: any) => `value=${ctx.value}`
|
|
39
|
+
const { target, destroy } = mountFlex({
|
|
40
|
+
content: fn,
|
|
41
|
+
context: { value: 42 } as any,
|
|
42
|
+
})
|
|
43
|
+
try {
|
|
44
|
+
expect(target.textContent).toContain('value=42')
|
|
45
|
+
} finally {
|
|
46
|
+
destroy()
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('renders nothing when content is undefined', () => {
|
|
51
|
+
const { target, destroy } = mountFlex({
|
|
52
|
+
content: undefined,
|
|
53
|
+
context: {} as any,
|
|
54
|
+
})
|
|
55
|
+
try {
|
|
56
|
+
expect(target.textContent?.trim()).toBe('')
|
|
57
|
+
} finally {
|
|
58
|
+
destroy()
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('pulls content + context from `cell` when provided', () => {
|
|
63
|
+
const cell = {
|
|
64
|
+
column: { columnDef: { cell: (ctx: any) => `row=${ctx.row.id}` } },
|
|
65
|
+
getContext: () => ({ row: { id: 7 } }),
|
|
66
|
+
}
|
|
67
|
+
const { target, destroy } = mountFlex({ cell } as any)
|
|
68
|
+
try {
|
|
69
|
+
expect(target.textContent).toContain('row=7')
|
|
70
|
+
} finally {
|
|
71
|
+
destroy()
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('pulls content + context from `header` when provided', () => {
|
|
76
|
+
const header = {
|
|
77
|
+
column: { columnDef: { header: 'Total' } },
|
|
78
|
+
getContext: () => ({}),
|
|
79
|
+
}
|
|
80
|
+
const { target, destroy } = mountFlex({ header } as any)
|
|
81
|
+
try {
|
|
82
|
+
expect(target.textContent).toContain('Total')
|
|
83
|
+
} finally {
|
|
84
|
+
destroy()
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('pulls content + context from `footer` when provided', () => {
|
|
89
|
+
const footer = {
|
|
90
|
+
column: { columnDef: { footer: 'Sum' } },
|
|
91
|
+
getContext: () => ({}),
|
|
92
|
+
}
|
|
93
|
+
const { target, destroy } = mountFlex({ footer } as any)
|
|
94
|
+
try {
|
|
95
|
+
expect(target.textContent).toContain('Sum')
|
|
96
|
+
} finally {
|
|
97
|
+
destroy()
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('renders a RenderComponentConfig result (smoke)', () => {
|
|
102
|
+
// Anonymous Svelte-like component would require compilation; we just
|
|
103
|
+
// exercise the constructor path so the discriminator branch executes.
|
|
104
|
+
const fn = () => new RenderComponentConfig(null as any, {} as any)
|
|
105
|
+
const { destroy } = mountFlex({
|
|
106
|
+
content: fn,
|
|
107
|
+
context: {} as any,
|
|
108
|
+
})
|
|
109
|
+
try {
|
|
110
|
+
// No throw means the discriminator was hit.
|
|
111
|
+
expect(true).toBe(true)
|
|
112
|
+
} finally {
|
|
113
|
+
destroy()
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('renders a RenderSnippetConfig result (smoke)', () => {
|
|
118
|
+
const fakeSnippet = (() => null) as any
|
|
119
|
+
const fn = () => new RenderSnippetConfig(fakeSnippet, undefined as any)
|
|
120
|
+
const { destroy } = mountFlex({
|
|
121
|
+
content: fn,
|
|
122
|
+
context: {} as any,
|
|
123
|
+
})
|
|
124
|
+
try {
|
|
125
|
+
expect(true).toBe(true)
|
|
126
|
+
} finally {
|
|
127
|
+
destroy()
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('renders a raw scalar value when the function returns one', () => {
|
|
132
|
+
const fn = () => 99
|
|
133
|
+
const { target, destroy } = mountFlex({
|
|
134
|
+
content: fn,
|
|
135
|
+
context: {} as any,
|
|
136
|
+
})
|
|
137
|
+
try {
|
|
138
|
+
expect(target.textContent).toContain('99')
|
|
139
|
+
} finally {
|
|
140
|
+
destroy()
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { applyGroupAggregate } from './core'
|
|
3
|
+
import type { Row } from './core'
|
|
4
|
+
|
|
5
|
+
type R = { v: number; cat: string }
|
|
6
|
+
|
|
7
|
+
// Minimal Row stubs - applyGroupAggregate only touches getCellValueByColumnId
|
|
8
|
+
// and original.
|
|
9
|
+
function rows(values: number[]): Row<R>[] {
|
|
10
|
+
return values.map(
|
|
11
|
+
(v) =>
|
|
12
|
+
({
|
|
13
|
+
original: { v, cat: 'x' } as R,
|
|
14
|
+
getCellValueByColumnId: (id: string) => (id === 'v' ? v : 'x'),
|
|
15
|
+
}) as unknown as Row<R>,
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('applyGroupAggregate', () => {
|
|
20
|
+
const r = rows([10, 20, 30])
|
|
21
|
+
|
|
22
|
+
it('sum / avg / min / max', () => {
|
|
23
|
+
expect(applyGroupAggregate('sum', 'v', r)).toBe(60)
|
|
24
|
+
expect(applyGroupAggregate('avg', 'v', r)).toBe(20)
|
|
25
|
+
expect(applyGroupAggregate('min', 'v', r)).toBe(10)
|
|
26
|
+
expect(applyGroupAggregate('max', 'v', r)).toBe(30)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('count / countDistinct / first / extent', () => {
|
|
30
|
+
expect(applyGroupAggregate('count', 'v', r)).toBe(3)
|
|
31
|
+
expect(applyGroupAggregate('countDistinct', 'cat', r)).toBe(1)
|
|
32
|
+
expect(applyGroupAggregate('first', 'v', r)).toBe(10)
|
|
33
|
+
expect(applyGroupAggregate('extent', 'v', r)).toBe('10 – 30')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('custom function receives numeric values + rows', () => {
|
|
37
|
+
const median = applyGroupAggregate(
|
|
38
|
+
(vals) => vals.sort((a, b) => a - b)[Math.floor(vals.length / 2)],
|
|
39
|
+
'v',
|
|
40
|
+
r,
|
|
41
|
+
)
|
|
42
|
+
expect(median).toBe(20)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('numeric reducers ignore non-numeric and empty groups', () => {
|
|
46
|
+
expect(applyGroupAggregate('sum', 'v', rows([]))).toBeUndefined()
|
|
47
|
+
})
|
|
48
|
+
})
|