@svgrid/enterprise 1.0.1

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/src/index.ts ADDED
@@ -0,0 +1,88 @@
1
+ export {
2
+ setLicenseKey,
3
+ clearLicenseKey,
4
+ getLicenseKey,
5
+ isLicenseKeySet,
6
+ hasValidLicense,
7
+ assertProLicensed,
8
+ } from './license'
9
+
10
+ export {
11
+ exportGrid,
12
+ type ExportFormat,
13
+ type ExportOptions,
14
+ type ExportColumn,
15
+ type ExportCellStyle,
16
+ type ExportStyles,
17
+ type ExportHeaderFooterLine,
18
+ type ExportSheet,
19
+ } from './export'
20
+ export { printGrid, type PrintOptions } from './print'
21
+ export {
22
+ importData,
23
+ type ImportFormat,
24
+ type ImportFieldType,
25
+ type ImportOptions,
26
+ type ImportResult,
27
+ type ImportColumnMap,
28
+ type ImportColumnTypes,
29
+ type ImportRowError,
30
+ type ImportValidator,
31
+ } from './import'
32
+ export { installPro, type ProGridApi, type ProAIApi, type ProPivotApi } from './install'
33
+ export {
34
+ createStagedEditing,
35
+ type StagedChange,
36
+ type StagedEditingApi,
37
+ type StagedEditingEvent,
38
+ } from './staged-editing'
39
+ export { dismissUnlicensedNudge } from './watermark'
40
+ export {
41
+ showUpgradePrompt,
42
+ dismissUpgradePrompt,
43
+ type ProFeatureLabel,
44
+ } from './upgrade-prompt'
45
+
46
+ export {
47
+ createPivotModel,
48
+ pivotAggregators,
49
+ filterCollapsedPivotRows,
50
+ type PivotAggregator,
51
+ type PivotAggregatorId,
52
+ type PivotConfig,
53
+ type PivotResult,
54
+ type PivotRow,
55
+ type PivotRowKind,
56
+ type PivotValueConfig,
57
+ } from './pivot'
58
+
59
+ export {
60
+ // Provider plumbing
61
+ setAIProvider,
62
+ getAIProvider,
63
+ hasAIProvider,
64
+ mockAIProvider,
65
+ type AIProvider,
66
+ type AIRequest,
67
+ type AITask,
68
+ // Filter
69
+ aiFilter,
70
+ type AIFilterOptions,
71
+ type AIFilterResult,
72
+ type AIFilterClause,
73
+ type AISortClause,
74
+ // Smart fill
75
+ aiSmartFill,
76
+ type AISmartFillOptions,
77
+ type AISmartFillResult,
78
+ type AISmartFillExample,
79
+ // Summarize
80
+ aiSummarize,
81
+ type AISummarizeOptions,
82
+ type AISummarizeTarget,
83
+ type AISummary,
84
+ // Classify
85
+ aiClassify,
86
+ type AIClassifyOptions,
87
+ type AIClassifyResult,
88
+ } from './ai'
package/src/install.ts ADDED
@@ -0,0 +1,114 @@
1
+ import type { RowData, TableFeatures, SvGridApi } from '@svgrid/grid'
2
+ import { exportGrid, type ExportOptions } from './export'
3
+ import { printGrid, type PrintOptions } from './print'
4
+ import { importData, type ImportOptions, type ImportResult } from './import'
5
+ import { isLicenseKeySet } from './license'
6
+ import { emitUnlicensedNudge } from './watermark'
7
+ import {
8
+ aiFilter, aiSmartFill, aiSummarize, aiClassify,
9
+ type AIFilterOptions, type AIFilterResult,
10
+ type AISmartFillOptions, type AISmartFillResult,
11
+ type AISummarizeOptions, type AISummary,
12
+ type AIClassifyOptions, type AIClassifyResult,
13
+ } from './ai'
14
+ import {
15
+ createPivotModel,
16
+ type PivotConfig,
17
+ type PivotResult,
18
+ } from './pivot'
19
+
20
+ export type ProAIApi<TData extends RowData> = {
21
+ /** Natural-language -> filter + sort plan (and optionally apply it). */
22
+ filter(query: string, opts?: AIFilterOptions): Promise<AIFilterResult>
23
+ /** Propose values for empty cells based on a few worked examples. */
24
+ smartFill<TValue = unknown>(opts: AISmartFillOptions): Promise<AISmartFillResult<TValue>>
25
+ /** One-paragraph + bullets summary of a row, selection, group, or the whole view. */
26
+ summarize(opts: AISummarizeOptions): Promise<AISummary>
27
+ /** Classify free-text cells into one of a known set of labels. */
28
+ classify(opts: AIClassifyOptions): Promise<AIClassifyResult>
29
+ // TData is referenced so the type stays bound to the row shape even
30
+ // though the helpers all read through `api.getData()`. Lets callers
31
+ // get correct inference downstream without explicit generics.
32
+ readonly _rowType?: TData
33
+ }
34
+
35
+ export type ProPivotApi<
36
+ TFeatures extends TableFeatures,
37
+ TData extends RowData,
38
+ > = {
39
+ /**
40
+ * Build a pivot row/column model from the grid's current data. Returns
41
+ * `{ rows, columns }` you can feed to a separate `<SvGrid>` instance
42
+ * (the typical pattern - the source grid stays unmodified). Pure: no
43
+ * mutation, no DOM access.
44
+ */
45
+ build(config: PivotConfig<TData>): PivotResult<TFeatures>
46
+ /**
47
+ * Build a pivot from an arbitrary array (not necessarily the grid's
48
+ * data). Useful for previewing a designer's config against a sample
49
+ * before committing.
50
+ */
51
+ buildFrom<TIn extends RowData>(
52
+ data: ReadonlyArray<TIn>,
53
+ config: PivotConfig<TIn>,
54
+ ): PivotResult<TFeatures>
55
+ }
56
+
57
+ export type ProGridApi<
58
+ TFeatures extends TableFeatures,
59
+ TData extends RowData,
60
+ > = SvGridApi<TFeatures, TData> & {
61
+ /** Export the current visible rows to the chosen format. */
62
+ exportData(opts: ExportOptions<TData>): Promise<void>
63
+ /** Open a printable view of the current visible rows in a new window. */
64
+ print(opts?: PrintOptions<TData>): Promise<void>
65
+ /** Read an Excel / CSV / TSV / JSON file (or inline text) into typed
66
+ * rows. Caller decides whether to commit into the grid or preview
67
+ * the parsed result first via the returned `ImportResult`. */
68
+ importData(opts: ImportOptions<TData>): Promise<ImportResult<TData>>
69
+ /** AI helpers (Pro). All calls route through a consumer-registered
70
+ * AIProvider via setAIProvider(); no model client is bundled. */
71
+ ai: ProAIApi<TData>
72
+ /** Pivot table builder (Pro). Pure - returns `{ rows, columns }` you
73
+ * hand to a separate `<SvGrid>` instance. */
74
+ pivot: ProPivotApi<TFeatures, TData>
75
+ }
76
+
77
+ /**
78
+ * Augment a SvGridApi instance with Pro methods (`exportData`, `print`,
79
+ * `ai.*`). Mutates and returns the same object so existing references
80
+ * keep working.
81
+ *
82
+ * If no license key has been set, the Pro methods still function but the
83
+ * grid shows a small "unlicensed" watermark linking to jqwidgets.com and
84
+ * the console emits a one-time nudge. Revoked or malformed keys throw on
85
+ * first call.
86
+ */
87
+ export function installPro<
88
+ TFeatures extends TableFeatures,
89
+ TData extends RowData,
90
+ >(api: SvGridApi<TFeatures, TData>): ProGridApi<TFeatures, TData> {
91
+ const pro = api as ProGridApi<TFeatures, TData>
92
+ pro.exportData = (opts) => exportGrid(pro, opts)
93
+ pro.print = (opts) => printGrid(pro, opts)
94
+ pro.importData = (opts) => importData(pro, opts)
95
+ pro.ai = {
96
+ filter: (query, opts) => aiFilter(pro, query, opts),
97
+ smartFill: (opts) => aiSmartFill(pro, opts),
98
+ summarize: (opts) => aiSummarize(pro, opts),
99
+ classify: (opts) => aiClassify(pro, opts),
100
+ }
101
+ pro.pivot = {
102
+ build: (config) =>
103
+ createPivotModel<TFeatures, TData>(pro.getData(), config),
104
+ buildFrom: <TIn extends RowData>(data: ReadonlyArray<TIn>, config: PivotConfig<TIn>) =>
105
+ // PivotConfig is variance-strict in TIn; the runtime call only needs
106
+ // the row shape to match config.rows / config.cols field names, which
107
+ // the caller-side generic enforces.
108
+ createPivotModel<TFeatures, TIn>(data, config),
109
+ }
110
+ // Surface the soft-gate state at install time so the watermark appears
111
+ // alongside the grid, not just after the user clicks Export.
112
+ if (!isLicenseKeySet()) emitUnlicensedNudge()
113
+ return pro
114
+ }
package/src/license.ts ADDED
@@ -0,0 +1,90 @@
1
+ // Polite license gate. Not crypto - anyone with devtools can extract the key
2
+ // from a deployed bundle. The point is to make commercial use require a
3
+ // transaction, not to defeat reverse engineering.
4
+ //
5
+ // Behavior matrix:
6
+ //
7
+ // currentKey state -> result
8
+ // ────────────────────────────────────────────────────────────────────
9
+ // null (no key set) -> soft-gate: watermark + console.log,
10
+ // feature still runs
11
+ // does not start with "SVPRO-" -> throws (programmer error)
12
+ // in REVOKED_KEYS -> throws (revoked / leaked / expired)
13
+ // starts with "SVPRO-DEV" or
14
+ // "SVPRO-EVAL" -> works; one-time console.info notice
15
+ // any other "SVPRO-..." -> works silently (paid production)
16
+
17
+ import { REVOKED_KEYS } from './revoked'
18
+ import { emitUnlicensedNudge } from './watermark'
19
+ import { showUpgradePrompt, type ProFeatureLabel } from './upgrade-prompt'
20
+
21
+ const VALID_PREFIX = 'SVPRO-'
22
+ let currentKey: string | null = null
23
+ let noticedDev = false
24
+
25
+ export function setLicenseKey(key: string): void {
26
+ if (typeof key !== 'string' || key.length === 0) {
27
+ throw new Error('@svgrid/enterprise: setLicenseKey() requires a non-empty string')
28
+ }
29
+ currentKey = key
30
+ noticedDev = false
31
+ }
32
+
33
+ export function clearLicenseKey(): void {
34
+ currentKey = null
35
+ noticedDev = false
36
+ }
37
+
38
+ export function getLicenseKey(): string | null {
39
+ return currentKey
40
+ }
41
+
42
+ /** True if a key is set at all (regardless of validity). */
43
+ export function isLicenseKeySet(): boolean {
44
+ return currentKey != null
45
+ }
46
+
47
+ /**
48
+ * True if the current key passes every check: present, valid prefix, not
49
+ * revoked. Use this from callers that want to branch on license status
50
+ * (e.g. UI that hides Pro-only options when unlicensed).
51
+ */
52
+ export function hasValidLicense(): boolean {
53
+ if (currentKey == null) return false
54
+ if (!currentKey.startsWith(VALID_PREFIX)) return false
55
+ if (REVOKED_KEYS.has(currentKey)) return false
56
+ return true
57
+ }
58
+
59
+ export function assertProLicensed(feature?: ProFeatureLabel): void {
60
+ if (currentKey == null) {
61
+ // Soft-gate: the feature still runs, but the user gets a watermark +
62
+ // a one-time console.log nudge directing them to pricing, plus a
63
+ // contextual moment-of-intent upgrade card naming the feature they
64
+ // just reached for.
65
+ emitUnlicensedNudge()
66
+ showUpgradePrompt(feature)
67
+ return
68
+ }
69
+ if (!currentKey.startsWith(VALID_PREFIX)) {
70
+ throw new Error(
71
+ '@svgrid/enterprise: invalid license key format (expected "SVPRO-..." prefix).',
72
+ )
73
+ }
74
+ if (REVOKED_KEYS.has(currentKey)) {
75
+ throw new Error(
76
+ '@svgrid/enterprise: this license key has been revoked. ' +
77
+ 'Contact sales@jqwidgets.com for a replacement.',
78
+ )
79
+ }
80
+ if (
81
+ !noticedDev &&
82
+ (currentKey.startsWith('SVPRO-DEV') || currentKey.startsWith('SVPRO-EVAL'))
83
+ ) {
84
+ // eslint-disable-next-line no-console
85
+ console.info(
86
+ '@svgrid/enterprise: using a development / evaluation license. Not for production use.',
87
+ )
88
+ noticedDev = true
89
+ }
90
+ }
@@ -0,0 +1,22 @@
1
+ // Ambient declarations for pdfmake submodules we lazy-import. pdfmake
2
+ // itself ships no .d.ts files and there is no @types/pdfmake on npm that
3
+ // covers `build/*`. Kept as a separate script-style .d.ts (no top-level
4
+ // import/export) so it functions as an ambient declaration, not a module
5
+ // augmentation. Pulled into export.ts via a triple-slash reference so
6
+ // consumers of @svgrid/enterprise pick the declarations up automatically.
7
+
8
+ declare module 'pdfmake/build/pdfmake' {
9
+ const pdfMake: {
10
+ vfs?: Record<string, string>
11
+ createPdf(definition: unknown): {
12
+ download(filename: string): void
13
+ getBlob(callback: (blob: Blob) => void): void
14
+ }
15
+ }
16
+ export default pdfMake
17
+ }
18
+
19
+ declare module 'pdfmake/build/vfs_fonts' {
20
+ const vfsFonts: unknown
21
+ export default vfsFonts
22
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Unit tests for the Pro pivot module: createPivotModel,
3
+ * filterCollapsedPivotRows, and pivotAggregators.
4
+ */
5
+ import { describe, expect, it } from 'vitest'
6
+ import {
7
+ createPivotModel,
8
+ filterCollapsedPivotRows,
9
+ pivotAggregators,
10
+ type PivotRow,
11
+ } from './pivot'
12
+
13
+ type Sale = {
14
+ region: 'AMER' | 'EMEA' | 'APAC'
15
+ salesPerson: string
16
+ quarter: '2026 Q1' | '2026 Q2'
17
+ amount: number
18
+ units: number
19
+ }
20
+
21
+ const facts: Sale[] = [
22
+ { region: 'AMER', salesPerson: 'Ada', quarter: '2026 Q1', amount: 100, units: 1 },
23
+ { region: 'AMER', salesPerson: 'Ada', quarter: '2026 Q2', amount: 200, units: 2 },
24
+ { region: 'AMER', salesPerson: 'Linus', quarter: '2026 Q1', amount: 300, units: 3 },
25
+ { region: 'AMER', salesPerson: 'Linus', quarter: '2026 Q2', amount: 50, units: 1 },
26
+ { region: 'EMEA', salesPerson: 'Tim', quarter: '2026 Q1', amount: 400, units: 4 },
27
+ { region: 'EMEA', salesPerson: 'Tim', quarter: '2026 Q2', amount: 100, units: 5 },
28
+ ]
29
+
30
+ describe('createPivotModel - shape of the result', () => {
31
+ it('emits rows for each row-axis level plus a grand-total row', () => {
32
+ const r = createPivotModel(facts, {
33
+ rows: ['region', 'salesPerson'],
34
+ cols: ['quarter'],
35
+ values: [{ field: 'amount', agg: 'sum' }],
36
+ })
37
+ // 2 regions × (1 group + N leaves) + grand total = AMER + Ada + Linus + EMEA + Tim + Grand
38
+ expect(r.rows.length).toBe(6)
39
+ expect(r.rows.map((row) => row.__pivotKind)).toEqual([
40
+ 'group', 'leaf', 'leaf', 'group', 'leaf', 'grandTotal',
41
+ ])
42
+ expect(r.rows.map((row) => row.__pivotLabel)).toEqual([
43
+ 'AMER', 'Ada', 'Linus', 'EMEA', 'Tim', 'Grand total',
44
+ ])
45
+ })
46
+
47
+ it('marks group rows as expandable and leaf rows as not', () => {
48
+ const r = createPivotModel(facts, {
49
+ rows: ['region', 'salesPerson'],
50
+ cols: ['quarter'],
51
+ values: [{ field: 'amount', agg: 'sum' }],
52
+ })
53
+ const exp = r.rows.map((row) => row.__pivotExpandable)
54
+ expect(exp).toEqual([true, false, false, true, false, false])
55
+ })
56
+
57
+ it('points each row.__pivotParentId at its row-axis parent', () => {
58
+ const r = createPivotModel(facts, {
59
+ rows: ['region', 'salesPerson'],
60
+ cols: ['quarter'],
61
+ values: [{ field: 'amount', agg: 'sum' }],
62
+ })
63
+ const byId = new Map(r.rows.map((row) => [row.__pivotId, row]))
64
+ const ada = r.rows.find((row) => row.__pivotLabel === 'Ada')!
65
+ const amer = r.rows.find((row) => row.__pivotLabel === 'AMER')!
66
+ expect(ada.__pivotParentId).toBe(amer.__pivotId)
67
+ expect(amer.__pivotParentId).toBeNull()
68
+ expect(byId.get(ada.__pivotParentId!)).toBe(amer)
69
+ })
70
+ })
71
+
72
+ describe('createPivotModel - aggregated values', () => {
73
+ it('computes sum per (row-leaf × col-leaf) cell', () => {
74
+ const r = createPivotModel(facts, {
75
+ rows: ['region', 'salesPerson'],
76
+ cols: ['quarter'],
77
+ values: [{ field: 'amount', agg: 'sum' }],
78
+ })
79
+ const ada = r.rows.find((row) => row.__pivotLabel === 'Ada')!
80
+ const q1Col = r.columns
81
+ .flatMap((c: any) => (c.columns ? c.columns : [c]))
82
+ .find((c: any) => c.id?.startsWith('pv__') && c.header === 'amount (sum)')
83
+ expect(q1Col).toBeDefined()
84
+ // Ada's Q1 sum is 100; Q2 sum is 200. Both leaves exist as separate ids.
85
+ const q1Id = Object.keys(ada).find((k) => k.startsWith('pv__2026') && k.includes('Q1'))!
86
+ const q2Id = Object.keys(ada).find((k) => k.startsWith('pv__2026') && k.includes('Q2'))!
87
+ expect(ada[q1Id]).toBe(100)
88
+ expect(ada[q2Id]).toBe(200)
89
+ })
90
+
91
+ it('emits a group row with the subtotal across all children', () => {
92
+ const r = createPivotModel(facts, {
93
+ rows: ['region', 'salesPerson'],
94
+ cols: ['quarter'],
95
+ values: [{ field: 'amount', agg: 'sum' }],
96
+ })
97
+ const amer = r.rows.find((row) => row.__pivotLabel === 'AMER')!
98
+ const q1Id = Object.keys(amer).find((k) => k.startsWith('pv__2026') && k.includes('Q1'))!
99
+ // AMER Q1 = Ada (100) + Linus (300) = 400
100
+ expect(amer[q1Id]).toBe(400)
101
+ })
102
+
103
+ it('emits a grand-total row that sums every fact', () => {
104
+ const r = createPivotModel(facts, {
105
+ rows: ['region', 'salesPerson'],
106
+ cols: ['quarter'],
107
+ values: [{ field: 'amount', agg: 'sum' }],
108
+ })
109
+ const grand = r.rows.find((row) => row.__pivotKind === 'grandTotal')!
110
+ const q1Id = Object.keys(grand).find((k) => k.startsWith('pv__2026') && k.includes('Q1'))!
111
+ // Q1 total: 100 + 300 + 400 = 800
112
+ expect(grand[q1Id]).toBe(800)
113
+ })
114
+
115
+ it('supports multiple measures (one column per measure × col-axis path)', () => {
116
+ const r = createPivotModel(facts, {
117
+ rows: ['region'],
118
+ cols: ['quarter'],
119
+ values: [
120
+ { field: 'amount', agg: 'sum' },
121
+ { field: 'units', agg: 'sum' },
122
+ ],
123
+ })
124
+ const amer = r.rows.find((row) => row.__pivotLabel === 'AMER')!
125
+ const amountKeys = Object.keys(amer).filter((k) => k.startsWith('pv__') && k.endsWith('__m0'))
126
+ const unitsKeys = Object.keys(amer).filter((k) => k.startsWith('pv__') && k.endsWith('__m1'))
127
+ // 2 col-axis values (Q1, Q2) + 1 grand-total column = 3 keys per measure
128
+ expect(amountKeys.length).toBe(3)
129
+ expect(unitsKeys.length).toBe(3)
130
+ })
131
+
132
+ it('honours a custom aggregator function', () => {
133
+ // Weighted average via custom function
134
+ const r = createPivotModel(facts, {
135
+ rows: ['region'],
136
+ cols: [],
137
+ values: [
138
+ {
139
+ field: 'amount', label: 'weighted', agg: (vs) => {
140
+ const ns = vs.map(Number).filter((n) => !Number.isNaN(n))
141
+ if (ns.length === 0) return 0
142
+ return ns.reduce((a, b) => a + b, 0) / ns.length
143
+ },
144
+ },
145
+ ],
146
+ })
147
+ const amer = r.rows.find((row) => row.__pivotLabel === 'AMER')!
148
+ const k = Object.keys(amer).find((k) => k.startsWith('pv__'))!
149
+ // AMER amounts: 100, 200, 300, 50 -> avg = 162.5
150
+ expect(amer[k]).toBeCloseTo(162.5, 5)
151
+ })
152
+ })
153
+
154
+ describe('createPivotModel - configuration toggles', () => {
155
+ it('omits the grand-total row when grandTotalRow=false', () => {
156
+ const r = createPivotModel(facts, {
157
+ rows: ['region'],
158
+ cols: [],
159
+ values: [{ field: 'amount', agg: 'sum' }],
160
+ grandTotalRow: false,
161
+ })
162
+ expect(r.rows.find((row) => row.__pivotKind === 'grandTotal')).toBeUndefined()
163
+ })
164
+
165
+ it('omits row subtotals when rowSubtotals=false', () => {
166
+ const r = createPivotModel(facts, {
167
+ rows: ['region', 'salesPerson'],
168
+ cols: ['quarter'],
169
+ values: [{ field: 'amount', agg: 'sum' }],
170
+ rowSubtotals: false,
171
+ })
172
+ // Only leaves + grand total - no `group` rows.
173
+ expect(r.rows.filter((row) => row.__pivotKind === 'group')).toEqual([])
174
+ })
175
+
176
+ it('honours a custom rowSort comparator', () => {
177
+ const r = createPivotModel(facts, {
178
+ rows: ['region'],
179
+ cols: [],
180
+ values: [{ field: 'amount', agg: 'sum' }],
181
+ rowSort: (a, b) => String(b).localeCompare(String(a)), // reverse-alpha
182
+ })
183
+ const regionLabels = r.rows
184
+ .filter((row) => row.__pivotKind === 'leaf')
185
+ .map((row) => row.__pivotLabel)
186
+ expect(regionLabels).toEqual(['EMEA', 'AMER'])
187
+ })
188
+ })
189
+
190
+ describe('pivotAggregators - built-ins', () => {
191
+ it('exposes sum / avg / min / max / count / countDistinct / first / last', () => {
192
+ expect(Object.keys(pivotAggregators).sort()).toEqual([
193
+ 'avg', 'count', 'countDistinct', 'first', 'last', 'max', 'min', 'sum',
194
+ ])
195
+ })
196
+
197
+ it('sum + avg coerce non-numeric values to NaN and drop them', () => {
198
+ expect(pivotAggregators.sum([10, 20, 'oops', null, 30])).toBe(60)
199
+ expect(pivotAggregators.avg([10, 20, 30])).toBe(20)
200
+ })
201
+
202
+ it('avg returns null for empty inputs (vs. 0, to distinguish from a real 0)', () => {
203
+ expect(pivotAggregators.avg([])).toBeNull()
204
+ })
205
+
206
+ it('countDistinct deduplicates by strict equality', () => {
207
+ expect(pivotAggregators.countDistinct(['a', 'a', 'b', 'b', 'c'])).toBe(3)
208
+ })
209
+
210
+ it('first/last pick the corresponding entries', () => {
211
+ expect(pivotAggregators.first(['x', 'y', 'z'])).toBe('x')
212
+ expect(pivotAggregators.last(['x', 'y', 'z'])).toBe('z')
213
+ })
214
+ })
215
+
216
+ describe('filterCollapsedPivotRows', () => {
217
+ function pivot() {
218
+ return createPivotModel(facts, {
219
+ rows: ['region', 'salesPerson'],
220
+ cols: ['quarter'],
221
+ values: [{ field: 'amount', agg: 'sum' }],
222
+ })
223
+ }
224
+
225
+ it('returns every row when expanded is `true`', () => {
226
+ const r = pivot()
227
+ const out = filterCollapsedPivotRows(r.rows, true)
228
+ expect(out.length).toBe(r.rows.length)
229
+ })
230
+
231
+ it('hides leaves under collapsed groups (empty expansion set)', () => {
232
+ const r = pivot()
233
+ const out = filterCollapsedPivotRows(r.rows, [])
234
+ // Only top-level groups + grand-total stay; no leaves
235
+ expect(out.map((row) => row.__pivotLabel)).toEqual(['AMER', 'EMEA', 'Grand total'])
236
+ })
237
+
238
+ it('shows leaves of the one expanded group', () => {
239
+ const r = pivot()
240
+ const amer = r.rows.find((row) => row.__pivotLabel === 'AMER')!
241
+ const out = filterCollapsedPivotRows(r.rows, [amer.__pivotId])
242
+ expect(out.map((row) => row.__pivotLabel)).toEqual([
243
+ 'AMER', 'Ada', 'Linus', 'EMEA', 'Grand total',
244
+ ])
245
+ })
246
+
247
+ it('accepts a Set as well as an array', () => {
248
+ const r = pivot()
249
+ const amer = r.rows.find((row) => row.__pivotLabel === 'AMER')!
250
+ const out = filterCollapsedPivotRows(r.rows, new Set([amer.__pivotId]))
251
+ expect(out.find((row) => row.__pivotLabel === 'Ada')).toBeDefined()
252
+ expect(out.find((row) => row.__pivotLabel === 'Tim')).toBeUndefined()
253
+ })
254
+
255
+ it('does not mutate the input array', () => {
256
+ const r = pivot()
257
+ const original = r.rows.slice()
258
+ filterCollapsedPivotRows(r.rows, [])
259
+ expect(r.rows).toEqual(original)
260
+ })
261
+
262
+ it('always keeps rows with a null parentId visible (grand total, top groups)', () => {
263
+ const r = pivot()
264
+ const out = filterCollapsedPivotRows(r.rows, [])
265
+ const visibleKinds = out.map((row) => row.__pivotKind)
266
+ expect(visibleKinds).toContain('grandTotal')
267
+ expect(visibleKinds.filter((k) => k === 'group').length).toBe(2)
268
+ })
269
+ })
270
+
271
+ describe('createPivotModel - tracking via pivotId', () => {
272
+ it('produces stable pivotIds for the same source data + config', () => {
273
+ const a = createPivotModel(facts, {
274
+ rows: ['region'],
275
+ cols: [],
276
+ values: [{ field: 'amount', agg: 'sum' }],
277
+ })
278
+ const b = createPivotModel(facts, {
279
+ rows: ['region'],
280
+ cols: [],
281
+ values: [{ field: 'amount', agg: 'sum' }],
282
+ })
283
+ expect(a.rows.map((r) => r.__pivotId)).toEqual(b.rows.map((r) => r.__pivotId))
284
+ })
285
+
286
+ it('every row.__pivotId is unique within one pivot', () => {
287
+ const r = createPivotModel(facts, {
288
+ rows: ['region', 'salesPerson'],
289
+ cols: ['quarter'],
290
+ values: [{ field: 'amount', agg: 'sum' }],
291
+ })
292
+ const ids = r.rows.map((row) => row.__pivotId)
293
+ expect(new Set(ids).size).toBe(ids.length)
294
+ })
295
+
296
+ it('every row.__pivotParentId resolves to another row (or null)', () => {
297
+ const r = createPivotModel(facts, {
298
+ rows: ['region', 'salesPerson'],
299
+ cols: ['quarter'],
300
+ values: [{ field: 'amount', agg: 'sum' }],
301
+ })
302
+ const ids = new Set(r.rows.map((row) => row.__pivotId))
303
+ for (const row of r.rows) {
304
+ if (row.__pivotParentId === null) continue
305
+ expect(ids.has(row.__pivotParentId)).toBe(true)
306
+ }
307
+ })
308
+ })