@svgrid/enterprise 1.2.0 → 2.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.
Files changed (159) hide show
  1. package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
  2. package/dist/designer/assets/index-Dp44bTid.js +939 -0
  3. package/dist/designer/assets/index-RJp6x8tw.css +1 -0
  4. package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
  5. package/dist/designer/index.html +13 -0
  6. package/dist/node/studio.js +22554 -0
  7. package/package.json +10 -3
  8. package/src/SvAuthGate.svelte +115 -0
  9. package/src/SvBoard.dom.test.ts +67 -0
  10. package/src/SvBoard.svelte +192 -0
  11. package/src/SvExportMenu.svelte +553 -0
  12. package/src/SvFileInput.svelte +113 -0
  13. package/src/SvGridEditPanel.dom.test.ts +146 -0
  14. package/src/SvGridEditPanel.svelte +793 -0
  15. package/src/SvGridMasterDetail.svelte +95 -0
  16. package/src/SvImportDialog.svelte +1020 -0
  17. package/src/SvLookupInput.svelte +180 -0
  18. package/src/SvRecordDetail.dom.test.ts +137 -0
  19. package/src/SvRecordDetail.svelte +288 -0
  20. package/src/SvSchedule.dom.test.ts +57 -0
  21. package/src/SvSchedule.svelte +156 -0
  22. package/src/SvSchemaChart.svelte +233 -0
  23. package/src/SvSchemaDashboard.svelte +130 -0
  24. package/src/ai-export-pdf.test.ts +74 -0
  25. package/src/ai-export-xlsx.test.ts +87 -0
  26. package/src/ai-export.test.ts +110 -0
  27. package/src/ai.ts +1188 -782
  28. package/src/edit-panel.test.ts +213 -0
  29. package/src/edit-panel.ts +228 -0
  30. package/src/export-conditional.test.ts +60 -0
  31. package/src/export-conditional.ts +94 -0
  32. package/src/export-ooxml.test.ts +152 -0
  33. package/src/export-ooxml.ts +485 -0
  34. package/src/export-pdf.test.ts +138 -0
  35. package/src/export-pdf.ts +245 -0
  36. package/src/export-print.test.ts +66 -0
  37. package/src/export-print.ts +132 -0
  38. package/src/export-serialize.test.ts +56 -0
  39. package/src/export-serialize.ts +24 -0
  40. package/src/export-xls.ts +196 -0
  41. package/src/export-xlsx-roundtrip.test.ts +120 -0
  42. package/src/export-xlsx.test.ts +150 -0
  43. package/src/export.test.ts +349 -0
  44. package/src/export.ts +1452 -549
  45. package/src/import-automap.test.ts +131 -0
  46. package/src/import-hardening.test.ts +158 -0
  47. package/src/import.ts +1042 -648
  48. package/src/index.ts +282 -0
  49. package/src/install.ts +134 -114
  50. package/src/license-core.test.ts +23 -0
  51. package/src/license-core.ts +35 -0
  52. package/src/license.ts +103 -90
  53. package/src/master-detail.test.ts +61 -0
  54. package/src/master-detail.ts +42 -0
  55. package/src/print.ts +107 -127
  56. package/src/schema-designer.test.ts +121 -0
  57. package/src/schema-designer.ts +142 -0
  58. package/src/schema.test.ts +268 -0
  59. package/src/schema.ts +499 -0
  60. package/src/smart-shim.ts +107 -105
  61. package/src/sources/aggregate.test.ts +79 -0
  62. package/src/sources/aggregate.ts +102 -0
  63. package/src/sources/auth-supabase.test.ts +80 -0
  64. package/src/sources/auth-supabase.ts +87 -0
  65. package/src/sources/dashboard.kpi.test.ts +50 -0
  66. package/src/sources/dashboard.test.ts +61 -0
  67. package/src/sources/dashboard.ts +136 -0
  68. package/src/sources/field-inference.test.ts +60 -0
  69. package/src/sources/field-inference.ts +63 -0
  70. package/src/sources/filters.test.ts +58 -0
  71. package/src/sources/filters.ts +64 -0
  72. package/src/sources/index.ts +67 -0
  73. package/src/sources/introspect-supabase.test.ts +100 -0
  74. package/src/sources/introspect-supabase.ts +119 -0
  75. package/src/sources/realtime-supabase.test.ts +93 -0
  76. package/src/sources/realtime-supabase.ts +99 -0
  77. package/src/sources/relation-lookup.test.ts +99 -0
  78. package/src/sources/relation-lookup.ts +128 -0
  79. package/src/sources/rest-adapters.test.ts +95 -0
  80. package/src/sources/rest-adapters.ts +120 -0
  81. package/src/sources/rest.test.ts +104 -0
  82. package/src/sources/rest.ts +129 -0
  83. package/src/sources/schema-from-columns.test.ts +106 -0
  84. package/src/sources/schema-from-columns.ts +88 -0
  85. package/src/sources/supabase.test.ts +110 -0
  86. package/src/sources/supabase.ts +99 -0
  87. package/src/sources/with-entity-rules.test.ts +104 -0
  88. package/src/sources/with-entity-rules.ts +78 -0
  89. package/src/sources/with-relation-labels.test.ts +75 -0
  90. package/src/sources/with-relation-labels.ts +73 -0
  91. package/src/studio/bug-report.test.ts +101 -0
  92. package/src/studio/bug-report.ts +165 -0
  93. package/src/studio/cli.test.ts +187 -0
  94. package/src/studio/cli.ts +112 -0
  95. package/src/studio/csv.test.ts +90 -0
  96. package/src/studio/csv.ts +165 -0
  97. package/src/studio/db-connect-string.test.ts +94 -0
  98. package/src/studio/db-connect-string.ts +128 -0
  99. package/src/studio/emit-project.test.ts +923 -0
  100. package/src/studio/emit-project.ts +1273 -0
  101. package/src/studio/emit-schema.test.ts +94 -0
  102. package/src/studio/emit-schema.ts +920 -0
  103. package/src/studio/index.ts +195 -0
  104. package/src/studio/introspect-db.test.ts +186 -0
  105. package/src/studio/introspect-db.ts +312 -0
  106. package/src/studio/introspect-prisma.test.ts +99 -0
  107. package/src/studio/introspect-prisma.ts +175 -0
  108. package/src/studio/introspect.test.ts +172 -0
  109. package/src/studio/introspect.ts +310 -0
  110. package/src/studio/pipeline.test.ts +42 -0
  111. package/src/studio/project-robust.test.ts +157 -0
  112. package/src/studio/project.test.ts +473 -0
  113. package/src/studio/project.ts +916 -0
  114. package/src/studio/sample-data.test.ts +58 -0
  115. package/src/studio/sample-data.ts +200 -0
  116. package/src/studio/samples/ats.ts +202 -0
  117. package/src/studio/samples/clinic.ts +177 -0
  118. package/src/studio/samples/crm.ts +250 -0
  119. package/src/studio/samples/ecommerce.ts +187 -0
  120. package/src/studio/samples/events.ts +199 -0
  121. package/src/studio/samples/fleet.ts +184 -0
  122. package/src/studio/samples/gym.ts +213 -0
  123. package/src/studio/samples/hr.ts +193 -0
  124. package/src/studio/samples/index.ts +55 -0
  125. package/src/studio/samples/insurance.ts +195 -0
  126. package/src/studio/samples/inventory.ts +182 -0
  127. package/src/studio/samples/invoicing.ts +162 -0
  128. package/src/studio/samples/library.ts +180 -0
  129. package/src/studio/samples/live-data.test.ts +98 -0
  130. package/src/studio/samples/live-data.ts +308 -0
  131. package/src/studio/samples/projects.ts +190 -0
  132. package/src/studio/samples/realestate.ts +196 -0
  133. package/src/studio/samples/restaurant.ts +187 -0
  134. package/src/studio/samples/samples.test.ts +208 -0
  135. package/src/studio/samples/school.ts +197 -0
  136. package/src/studio/samples/seed-floor.test.ts +25 -0
  137. package/src/studio/samples/shared.ts +305 -0
  138. package/src/studio/samples/subscriptions.ts +183 -0
  139. package/src/studio/samples/support.ts +182 -0
  140. package/src/studio/scaffold-app.test.ts +91 -0
  141. package/src/studio/scaffold-app.ts +161 -0
  142. package/src/studio/scaffold.test.ts +178 -0
  143. package/src/studio/scaffold.ts +374 -0
  144. package/src/studio/themes.ts +172 -0
  145. package/src/studio/verify.test.ts +47 -0
  146. package/src/studio/verify.ts +79 -0
  147. package/src/sveltekit/in-memory.test.ts +104 -0
  148. package/src/sveltekit/in-memory.ts +129 -0
  149. package/src/sveltekit/index.ts +23 -0
  150. package/src/sveltekit/query-plan.test.ts +109 -0
  151. package/src/sveltekit/query-plan.ts +125 -0
  152. package/src/sveltekit/sql-source.test.ts +185 -0
  153. package/src/sveltekit/sql-source.ts +166 -0
  154. package/src/sveltekit/sql.test.ts +80 -0
  155. package/src/sveltekit/sql.ts +131 -0
  156. package/src/sveltekit/transport.test.ts +175 -0
  157. package/src/sveltekit/transport.ts +254 -0
  158. package/src/sveltekit/types.ts +11 -0
  159. package/src/upgrade-prompt.ts +149 -148
@@ -0,0 +1,349 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ // Capture what would be downloaded instead of touching the DOM / Blob URLs.
4
+ const downloads: Array<{ text: string; filename: string; mime: string }> = []
5
+ vi.mock('./export-serialize', async (importOriginal) => {
6
+ const actual = (await importOriginal()) as Record<string, unknown>
7
+ return {
8
+ ...actual,
9
+ downloadTextFile: (text: string, filename: string, mime: string) => {
10
+ downloads.push({ text, filename, mime })
11
+ },
12
+ }
13
+ })
14
+
15
+ import { exportGrid, copyExportToClipboard, buildGroupedPdfBody } from './export'
16
+ import { setLicenseKey } from './license'
17
+
18
+ type Row = { name: string; price: number; when: string; ratio: number }
19
+
20
+ const rows: Row[] = [
21
+ { name: 'ACME', price: 19.95, when: '2026-03-04', ratio: 42 },
22
+ { name: 'Globex, Inc', price: 1000, when: '2026-06-01', ratio: 8 },
23
+ ]
24
+
25
+ // Mimics the subset of SvGridApi the exporter reads.
26
+ function makeApi(opts: {
27
+ displayed?: Row[]
28
+ selected?: Row[]
29
+ all?: Row[]
30
+ grouping?: string[]
31
+ columns?: Array<Record<string, unknown>>
32
+ } = {}) {
33
+ const columns = opts.columns ?? [
34
+ { id: 'name', field: 'name', header: 'Company', visible: true, align: 'left' },
35
+ { id: 'price', field: 'price', header: 'Price', visible: true, format: { type: 'currency', currency: 'USD', locales: 'en-US' }, align: 'right' },
36
+ { id: 'when', field: 'when', header: 'Date', visible: true, format: { type: 'date', pattern: 'y-m-d', locales: 'en-US' } },
37
+ { id: 'ratio', field: 'ratio', header: 'Ratio', visible: true, format: { type: 'percent', valueIsPercentPoints: true, locales: 'en-US' }, align: 'right' },
38
+ ]
39
+ return {
40
+ getDisplayedRows: () => opts.displayed ?? rows,
41
+ getSelectedRows: () => opts.selected ?? [],
42
+ getData: () => opts.all ?? rows,
43
+ getColumns: () => columns,
44
+ getState: () => ({ grouping: opts.grouping ?? [] }),
45
+ getColumnPinning: () => ({ left: [], right: [] }),
46
+ } as any
47
+ }
48
+
49
+ beforeEach(() => {
50
+ downloads.length = 0
51
+ setLicenseKey('SVENTERPRISE-DEV-TEST')
52
+ })
53
+
54
+ describe('exportGrid - faithful values (P0)', () => {
55
+ it('formats currency / date / percent like the grid does (csv)', async () => {
56
+ await exportGrid(makeApi(), { format: 'csv', filename: 'orders' })
57
+ expect(downloads).toHaveLength(1)
58
+ const { text, filename, mime } = downloads[0]!
59
+ expect(filename).toBe('orders.csv')
60
+ expect(mime).toContain('text/csv')
61
+ expect(text).toContain('Company,Price,Date,Ratio')
62
+ expect(text).toContain('$19.95')
63
+ expect(text).toContain('42%')
64
+ expect(text).toContain('2026') // formatted date, not raw passthrough only
65
+ // comma-bearing value is quoted (RFC-4180)
66
+ expect(text).toContain('"Globex, Inc"')
67
+ })
68
+
69
+ it('rawValues:true writes underlying values, not formatted', async () => {
70
+ await exportGrid(makeApi(), { format: 'csv', rawValues: true })
71
+ const { text } = downloads[0]!
72
+ expect(text).toContain('19.95')
73
+ expect(text).not.toContain('$19.95')
74
+ })
75
+
76
+ it('exportValue hook overrides the field value', async () => {
77
+ await exportGrid(makeApi(), {
78
+ format: 'csv',
79
+ columns: [{ field: 'name', header: 'Company', exportValue: (r: Row) => r.name.toUpperCase() }],
80
+ })
81
+ expect(downloads[0]!.text).toContain('ACME')
82
+ expect(downloads[0]!.text).toContain('"GLOBEX, INC"') // uppercased + comma-quoted
83
+ })
84
+ })
85
+
86
+ describe('exportGrid - ExportResult / download:false', () => {
87
+ it('returns the built file and downloads by default', async () => {
88
+ const res = await exportGrid(makeApi(), { format: 'csv', filename: 'r' })
89
+ expect(downloads).toHaveLength(1)
90
+ expect(res).toBeDefined()
91
+ expect(res!.filename).toBe('r.csv')
92
+ expect(res!.mime).toContain('text/csv')
93
+ expect(res!.rowCount).toBe(2)
94
+ expect(res!.byteSize).toBeGreaterThan(0)
95
+ expect(res!.blob).toBeInstanceOf(Blob)
96
+ })
97
+
98
+ it('download:false returns the result without downloading', async () => {
99
+ const res = await exportGrid(makeApi(), { format: 'csv', download: false })
100
+ expect(downloads).toHaveLength(0)
101
+ expect(res!.blob).toBeInstanceOf(Blob)
102
+ expect(await res!.blob.text()).toContain('$19.95')
103
+ })
104
+ })
105
+
106
+ describe('exportGrid - row scope (P1)', () => {
107
+ it("scope 'selected' exports only selected rows", async () => {
108
+ const api = makeApi({ selected: [rows[1]!] })
109
+ await exportGrid(api, { format: 'csv', rows: 'selected' })
110
+ const { text } = downloads[0]!
111
+ expect(text).toContain('Globex')
112
+ expect(text).not.toContain('ACME')
113
+ })
114
+
115
+ it("scope 'all' uses the full dataset", async () => {
116
+ const api = makeApi({ displayed: [rows[0]!], all: rows })
117
+ await exportGrid(api, { format: 'csv', rows: 'all' })
118
+ expect(downloads[0]!.text).toContain('Globex')
119
+ })
120
+
121
+ it('throws on an empty result set', async () => {
122
+ const api = makeApi({ displayed: [] })
123
+ await expect(exportGrid(api, { format: 'csv' })).rejects.toThrow(/nothing to export/)
124
+ })
125
+ })
126
+
127
+ describe('exportGrid - format tuning (P2/P3)', () => {
128
+ it('tsv uses a tab delimiter and .tsv extension', async () => {
129
+ await exportGrid(makeApi(), { format: 'tsv' })
130
+ const { text, filename } = downloads[0]!
131
+ expect(filename).toBe('grid.tsv')
132
+ expect(text).toContain('Company\tPrice\tDate\tRatio')
133
+ })
134
+
135
+ it('html escapes and aligns', async () => {
136
+ await exportGrid(makeApi(), { format: 'html', filename: 'report' })
137
+ const { text, filename, mime } = downloads[0]!
138
+ expect(filename).toBe('report.html')
139
+ expect(mime).toContain('text/html')
140
+ expect(text).toContain('<th>Company</th>')
141
+ expect(text).toContain('text-align:right') // price/ratio columns
142
+ })
143
+
144
+ it('custom delimiter + no BOM', async () => {
145
+ await exportGrid(makeApi(), { format: 'csv', csv: { delimiter: ';', bom: false } })
146
+ const { text } = downloads[0]!
147
+ expect(text.charCodeAt(0)).not.toBe(0xfeff)
148
+ expect(text).toContain('Company;Price;Date;Ratio')
149
+ })
150
+
151
+ it('reports progress', async () => {
152
+ const onProgress = vi.fn()
153
+ await exportGrid(makeApi(), { format: 'csv', onProgress })
154
+ expect(onProgress).toHaveBeenCalled()
155
+ })
156
+ })
157
+
158
+ describe('exportGrid - legacy xls (SpreadsheetML)', () => {
159
+ it('emits a valid Excel 2003 XML workbook with typed cells', async () => {
160
+ await exportGrid(makeApi(), { format: 'xls', filename: 'legacy' })
161
+ const { text, filename, mime } = downloads[0]!
162
+ expect(filename).toBe('legacy.xls')
163
+ expect(mime).toContain('application/vnd.ms-excel')
164
+ expect(text).toContain('<?mso-application progid="Excel.Sheet"?>')
165
+ expect(text).toContain('urn:schemas-microsoft-com:office:spreadsheet')
166
+ // numbers stay numeric (Number type), not stringified currency
167
+ expect(text).toContain('<Data ss:Type="Number">19.95</Data>')
168
+ // percent points become the 0..1 fraction Excel expects
169
+ expect(text).toContain('<Data ss:Type="Number">0.42</Data>')
170
+ // dates are DateTime-typed
171
+ expect(text).toContain('ss:Type="DateTime"')
172
+ // header row + escaping of the comma value (no CSV quoting in XML)
173
+ expect(text).toContain('Company')
174
+ expect(text).toContain('Globex, Inc')
175
+ // a number format style is registered for the currency column
176
+ expect(text).toContain('<NumberFormat')
177
+ })
178
+
179
+ it('escapes XML special characters', async () => {
180
+ const api = makeApi({
181
+ displayed: [{ name: 'A & <B>', price: 1, when: '2026-01-01', ratio: 1 }],
182
+ })
183
+ await exportGrid(api, { format: 'xls' })
184
+ expect(downloads[0]!.text).toContain('A &amp; &lt;B&gt;')
185
+ })
186
+
187
+ it('rawValues keeps raw numbers and skips number-format styles', async () => {
188
+ await exportGrid(makeApi(), { format: 'xls', rawValues: true })
189
+ const { text } = downloads[0]!
190
+ expect(text).toContain('<Data ss:Type="Number">19.95</Data>')
191
+ expect(text).toContain('<Data ss:Type="Number">42</Data>') // ratio raw, not 0.42
192
+ })
193
+
194
+ it('carries conditional formatting as real per-cell fills (xls)', async () => {
195
+ await exportGrid(makeApi(), {
196
+ format: 'xls',
197
+ conditionalFormats: [
198
+ { type: 'rule', columns: ['price'], when: ({ value }) => Number(value) >= 1000, background: '#fee2e2' },
199
+ ],
200
+ })
201
+ const { text } = downloads[0]!
202
+ expect(text).toContain('<Interior ss:Color="#fee2e2" ss:Pattern="Solid"/>')
203
+ })
204
+
205
+ it('freezes panes, sizes columns, and links cells (xls fidelity)', async () => {
206
+ await exportGrid(makeApi(), {
207
+ format: 'xls',
208
+ columns: [
209
+ { field: 'name', header: 'Company', link: (r: Row) => `https://x.test/${r.name}` },
210
+ { field: 'price', header: 'Price', format: { type: 'currency', currency: 'USD' } },
211
+ ],
212
+ freezeColumns: 1,
213
+ })
214
+ const { text } = downloads[0]!
215
+ expect(text).toContain('<FreezePanes/>') // freezeHeader defaults on
216
+ expect(text).toContain('<SplitVertical>1</SplitVertical>') // freezeColumns: 1
217
+ expect(text).toContain('<Column ss:Width=') // auto-fit widths
218
+ expect(text).toContain('ss:HRef="https://x.test/ACME"') // hyperlink
219
+ })
220
+ })
221
+
222
+ describe('exportGrid - JSON / XML / Markdown', () => {
223
+ it('json defaults to raw values and machine keys', async () => {
224
+ await exportGrid(makeApi(), { format: 'json', filename: 'data' })
225
+ const { text, filename, mime } = downloads[0]!
226
+ expect(filename).toBe('data.json')
227
+ expect(mime).toContain('application/json')
228
+ const parsed = JSON.parse(text)
229
+ expect(parsed).toHaveLength(2)
230
+ expect(parsed[0]).toMatchObject({ name: 'ACME', price: 19.95, ratio: 42 }) // raw
231
+ })
232
+
233
+ it('json honors rawValues:false (formatted strings)', async () => {
234
+ await exportGrid(makeApi(), { format: 'json', rawValues: false })
235
+ const parsed = JSON.parse(downloads[0]!.text)
236
+ expect(parsed[0].price).toBe('$19.95')
237
+ })
238
+
239
+ it('markdown emits a GFM table with alignment markers', async () => {
240
+ await exportGrid(makeApi(), { format: 'md' })
241
+ const { text, filename } = downloads[0]!
242
+ expect(filename).toBe('grid.md')
243
+ expect(text).toContain('| Company | Price | Date | Ratio |')
244
+ expect(text).toContain('---:') // right-aligned price/ratio
245
+ expect(text).toContain('$19.95')
246
+ })
247
+
248
+ it('xml escapes and uses field element names', async () => {
249
+ await exportGrid(makeApi(), { format: 'xml' })
250
+ const { text, filename, mime } = downloads[0]!
251
+ expect(filename).toBe('grid.xml')
252
+ expect(mime).toContain('application/xml')
253
+ expect(text).toContain('<?xml version="1.0" encoding="UTF-8"?>')
254
+ expect(text).toContain('<name>ACME</name>')
255
+ expect(text).toContain('<name>Globex, Inc</name>')
256
+ })
257
+
258
+ it('html carries conditional formatting into cell styles', async () => {
259
+ await exportGrid(makeApi(), {
260
+ format: 'html',
261
+ conditionalFormats: [
262
+ { type: 'rule', columns: ['price'], when: ({ value }) => Number(value) >= 1000, background: '#fee2e2' },
263
+ ],
264
+ })
265
+ expect(downloads[0]!.text).toContain('background:#fee2e2')
266
+ })
267
+
268
+ it('html renders hyperlinks from a column link hook', async () => {
269
+ await exportGrid(makeApi(), {
270
+ format: 'html',
271
+ columns: [{ field: 'name', header: 'Company', link: (r: Row) => `https://x.test/${r.name}` }],
272
+ })
273
+ expect(downloads[0]!.text).toContain('<a href="https://x.test/ACME">ACME</a>')
274
+ })
275
+ })
276
+
277
+ describe('buildGroupedPdfBody', () => {
278
+ const cols = [
279
+ { field: 'region', header: 'Region' },
280
+ { field: 'company', header: 'Company' },
281
+ { field: 'price', header: 'Price', format: { type: 'currency', currency: 'USD', locales: 'en-US' } as const },
282
+ ]
283
+ const gRows = [
284
+ { region: 'North', company: 'A', price: 10 },
285
+ { region: 'North', company: 'B', price: 20 },
286
+ { region: 'South', company: 'C', price: 5 },
287
+ ]
288
+
289
+ it('emits group header + data rows + a subtotal per cluster', () => {
290
+ const body = buildGroupedPdfBody(cols, gRows, ['region'])
291
+ expect(body.map((b) => b.kind)).toEqual([
292
+ 'group',
293
+ 'data',
294
+ 'data',
295
+ 'subtotal',
296
+ 'group',
297
+ 'data',
298
+ 'subtotal',
299
+ ])
300
+ expect(body[0]).toMatchObject({ kind: 'group', label: 'Region: North (2)' })
301
+ const subtotal = body[3] as { kind: 'subtotal'; cells: string[] }
302
+ expect(subtotal.cells[0]).toBe('Subtotal (North)') // label in first non-numeric col
303
+ expect(subtotal.cells[2]).toBe('$30.00') // summed currency
304
+ })
305
+
306
+ it('nests multi-level grouping', () => {
307
+ const body = buildGroupedPdfBody(cols, gRows, ['region', 'company'])
308
+ const groups = body.filter((b) => b.kind === 'group') as Array<{ label: string; level?: number }>
309
+ expect(groups.some((g) => g.level === 0 && g.label.startsWith('Region:'))).toBe(true)
310
+ expect(groups.some((g) => g.level === 1 && g.label.startsWith('Company:'))).toBe(true)
311
+ })
312
+ })
313
+
314
+ describe('copyExportToClipboard', () => {
315
+ afterEach(() => vi.unstubAllGlobals())
316
+
317
+ it('copies tsv by default via writeText', async () => {
318
+ const writeText = vi.fn(async (_text: string) => {})
319
+ vi.stubGlobal('navigator', { clipboard: { writeText } })
320
+ await copyExportToClipboard(makeApi(), {})
321
+ expect(writeText).toHaveBeenCalledTimes(1)
322
+ const text = writeText.mock.calls[0]![0]
323
+ expect(text).toContain('Company\tPrice')
324
+ expect(text).toContain('$19.95')
325
+ })
326
+
327
+ it('copies html as rich text/html via ClipboardItem', async () => {
328
+ const write = vi.fn(async () => {})
329
+ const items: Array<Record<string, Blob>> = []
330
+ vi.stubGlobal(
331
+ 'ClipboardItem',
332
+ class {
333
+ constructor(public data: Record<string, Blob>) {
334
+ items.push(data)
335
+ }
336
+ },
337
+ )
338
+ vi.stubGlobal('navigator', { clipboard: { write, writeText: vi.fn() } })
339
+ await copyExportToClipboard(makeApi(), { format: 'html' })
340
+ expect(write).toHaveBeenCalledTimes(1)
341
+ expect(items[0]).toHaveProperty('text/html')
342
+ expect(items[0]).toHaveProperty('text/plain')
343
+ })
344
+
345
+ it('throws when the clipboard API is unavailable', async () => {
346
+ vi.stubGlobal('navigator', {})
347
+ await expect(copyExportToClipboard(makeApi(), {})).rejects.toThrow(/clipboard is unavailable/)
348
+ })
349
+ })