@svgrid/grid 1.0.0 → 1.1.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 (144) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +137 -39
  3. package/dist/GridFooter.svelte +164 -0
  4. package/dist/GridFooter.svelte.d.ts +29 -0
  5. package/dist/GridMenus.svelte +570 -0
  6. package/dist/GridMenus.svelte.d.ts +31 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +429 -0
  8. package/dist/SvGrid.controller.svelte.js +1732 -0
  9. package/dist/SvGrid.css +1709 -0
  10. package/dist/SvGrid.helpers.d.ts +35 -0
  11. package/dist/SvGrid.helpers.js +160 -0
  12. package/dist/SvGrid.svelte +344 -7043
  13. package/dist/SvGrid.svelte.d.ts +4 -357
  14. package/dist/SvGrid.types.d.ts +436 -0
  15. package/dist/SvGrid.types.js +1 -0
  16. package/dist/SvGridChart.svelte +1060 -23
  17. package/dist/SvGridChart.svelte.d.ts +17 -0
  18. package/dist/build-api.d.ts +5 -0
  19. package/dist/build-api.js +527 -0
  20. package/dist/cell-render.d.ts +15 -0
  21. package/dist/cell-render.js +246 -0
  22. package/dist/cell-values.d.ts +28 -0
  23. package/dist/cell-values.js +89 -0
  24. package/dist/chart.d.ts +370 -3
  25. package/dist/chart.js +1135 -42
  26. package/dist/clipboard.d.ts +15 -0
  27. package/dist/clipboard.js +356 -0
  28. package/dist/columns.d.ts +30 -0
  29. package/dist/columns.js +277 -0
  30. package/dist/core.d.ts +1 -1
  31. package/dist/css.d.ts +3 -0
  32. package/dist/editing.d.ts +24 -0
  33. package/dist/editing.js +343 -0
  34. package/dist/editors/cell-editors.d.ts +1 -1
  35. package/dist/facet-buckets.d.ts +13 -0
  36. package/dist/facet-buckets.js +54 -0
  37. package/dist/features.d.ts +5 -0
  38. package/dist/features.js +30 -0
  39. package/dist/filter-operators.d.ts +16 -0
  40. package/dist/filter-operators.js +69 -0
  41. package/dist/hyperformula-adapter.d.ts +82 -0
  42. package/dist/hyperformula-adapter.js +73 -0
  43. package/dist/index.d.ts +5 -2
  44. package/dist/index.js +5 -2
  45. package/dist/keyboard-handlers.d.ts +7 -0
  46. package/dist/keyboard-handlers.js +197 -0
  47. package/dist/menus.d.ts +40 -0
  48. package/dist/menus.js +389 -0
  49. package/dist/named-views.d.ts +27 -0
  50. package/dist/named-views.js +39 -0
  51. package/dist/row-resize.d.ts +43 -0
  52. package/dist/row-resize.js +158 -0
  53. package/dist/scroll-sync.d.ts +9 -0
  54. package/dist/scroll-sync.js +86 -0
  55. package/dist/selection.d.ts +26 -0
  56. package/dist/selection.js +387 -0
  57. package/dist/spreadsheet.d.ts +80 -0
  58. package/dist/spreadsheet.js +194 -0
  59. package/dist/summaries.d.ts +12 -0
  60. package/dist/summaries.js +65 -0
  61. package/dist/sv-grid-scrollbar.js +7 -1
  62. package/dist/svgrid-wrapper.types.d.ts +12 -1
  63. package/dist/svgrid.comments-autocomplete.test.d.ts +1 -0
  64. package/dist/svgrid.comments-autocomplete.test.js +96 -0
  65. package/dist/svgrid.context-menu.test.d.ts +1 -0
  66. package/dist/svgrid.context-menu.test.js +102 -0
  67. package/dist/svgrid.new-features.wrapper.test.js +30 -4
  68. package/dist/svgrid.wrapper.test.js +27 -1
  69. package/dist/virtualization/column-virtualizer.d.ts +2 -0
  70. package/dist/virtualization/scroll-scaling.d.ts +28 -0
  71. package/dist/virtualization/scroll-scaling.js +64 -0
  72. package/dist/virtualization/scroll-scaling.test.d.ts +1 -0
  73. package/dist/virtualization/scroll-scaling.test.js +86 -0
  74. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +2 -0
  75. package/dist/virtualization/svelte-virtualizer.svelte.js +2 -0
  76. package/dist/virtualization/virtualizer.d.ts +7 -0
  77. package/dist/virtualization/virtualizer.js +30 -0
  78. package/package.json +1 -1
  79. package/src/GridFooter.svelte +164 -0
  80. package/src/GridMenus.svelte +570 -0
  81. package/src/SvGrid.controller.svelte.ts +2195 -0
  82. package/src/SvGrid.css +1747 -0
  83. package/src/SvGrid.helpers.test.ts +415 -0
  84. package/src/SvGrid.helpers.ts +185 -0
  85. package/src/SvGrid.svelte +348 -7043
  86. package/src/SvGrid.types.ts +456 -0
  87. package/src/SvGridChart.svelte +1060 -23
  88. package/src/build-api.coverage.test.ts +532 -0
  89. package/src/build-api.ts +663 -0
  90. package/src/cell-render.test.ts +451 -0
  91. package/src/cell-render.ts +426 -0
  92. package/src/cell-values.ts +114 -0
  93. package/src/chart-export.test.ts +370 -0
  94. package/src/chart.coverage.test.ts +814 -0
  95. package/src/chart.ts +1352 -47
  96. package/src/clipboard.test.ts +731 -0
  97. package/src/clipboard.ts +524 -0
  98. package/src/collaboration.coverage.test.ts +220 -0
  99. package/src/columns.test.ts +702 -0
  100. package/src/columns.ts +419 -0
  101. package/src/core.ts +8 -0
  102. package/src/css.d.ts +3 -0
  103. package/src/editing.test.ts +837 -0
  104. package/src/editing.ts +513 -0
  105. package/src/editors/cell-editors.coverage.test.ts +156 -0
  106. package/src/editors/cell-editors.ts +1 -0
  107. package/src/facet-buckets.test.ts +353 -0
  108. package/src/facet-buckets.ts +67 -0
  109. package/src/features.ts +128 -0
  110. package/src/filter-operators.test.ts +174 -0
  111. package/src/filter-operators.ts +87 -0
  112. package/src/hyperformula-adapter.test.ts +256 -0
  113. package/src/hyperformula-adapter.ts +124 -0
  114. package/src/index.ts +37 -0
  115. package/src/keyboard-handlers.coverage.test.ts +560 -0
  116. package/src/keyboard-handlers.ts +353 -0
  117. package/src/keyboard.ts +97 -97
  118. package/src/menus.test.ts +620 -0
  119. package/src/menus.ts +554 -0
  120. package/src/named-views.coverage.test.ts +210 -0
  121. package/src/named-views.ts +48 -0
  122. package/src/row-resize.test.ts +369 -0
  123. package/src/row-resize.ts +171 -0
  124. package/src/scroll-sync.test.ts +330 -0
  125. package/src/scroll-sync.ts +216 -0
  126. package/src/selection.test.ts +722 -0
  127. package/src/selection.ts +545 -0
  128. package/src/server-data-source.coverage.test.ts +180 -0
  129. package/src/spreadsheet.test.ts +445 -0
  130. package/src/spreadsheet.ts +246 -0
  131. package/src/summaries.ts +204 -0
  132. package/src/sv-grid-scrollbar.ts +21 -2
  133. package/src/svgrid-wrapper.types.ts +12 -1
  134. package/src/svgrid.behavior.test.ts +22 -0
  135. package/src/svgrid.comments-autocomplete.test.ts +112 -0
  136. package/src/svgrid.context-menu.test.ts +126 -0
  137. package/src/svgrid.interaction.test.ts +30 -0
  138. package/src/svgrid.new-features.wrapper.test.ts +65 -4
  139. package/src/svgrid.wrapper.test.ts +27 -1
  140. package/src/test-setup.ts +9 -6
  141. package/src/virtualization/scroll-scaling.test.ts +148 -0
  142. package/src/virtualization/scroll-scaling.ts +121 -0
  143. package/src/virtualization/svelte-virtualizer.svelte.ts +2 -0
  144. package/src/virtualization/virtualizer.ts +26 -0
@@ -0,0 +1,370 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ chartToSvgString,
4
+ chartToPngBlob,
5
+ downloadChartSvg,
6
+ downloadChartPng,
7
+ } from './chart-export'
8
+
9
+ const SVG_NS = 'http://www.w3.org/2000/svg'
10
+
11
+ /**
12
+ * Build a small but realistic chart SVG matching the structure
13
+ * chart-export expects: `svg.sv-grid-chart-svg` with a viewBox, an
14
+ * interaction hit layer (`.sv-grid-chart-hit`) that the exporter must
15
+ * strip, plus a few themed nodes.
16
+ */
17
+ function makeChartSvg(opts: { viewBox?: string } = {}): SVGSVGElement {
18
+ const svg = document.createElementNS(SVG_NS, 'svg') as SVGSVGElement
19
+ svg.setAttribute('class', 'sv-grid-chart-svg')
20
+ svg.setAttribute('viewBox', opts.viewBox ?? '0 0 400 250')
21
+
22
+ const axis = document.createElementNS(SVG_NS, 'text')
23
+ axis.setAttribute('class', 'sv-grid-chart-axis')
24
+ axis.textContent = 'A'
25
+ svg.appendChild(axis)
26
+
27
+ const hit = document.createElementNS(SVG_NS, 'rect')
28
+ hit.setAttribute('class', 'sv-grid-chart-hit')
29
+ svg.appendChild(hit)
30
+
31
+ const bar = document.createElementNS(SVG_NS, 'rect')
32
+ bar.setAttribute('class', 'sv-grid-chart-bar')
33
+ svg.appendChild(bar)
34
+
35
+ return svg
36
+ }
37
+
38
+ describe('chartToSvgString', () => {
39
+ it('serializes the chart svg passed directly as an SVGSVGElement', () => {
40
+ const svg = makeChartSvg()
41
+ const out = chartToSvgString(svg)
42
+ expect(out).toContain('<svg')
43
+ expect(out).toContain('xmlns="http://www.w3.org/2000/svg"')
44
+ })
45
+
46
+ it('resolves the inner svg.sv-grid-chart-svg from a wrapping element', () => {
47
+ const wrap = document.createElement('div')
48
+ wrap.appendChild(makeChartSvg())
49
+ const out = chartToSvgString(wrap)
50
+ expect(out).toContain('<svg')
51
+ })
52
+
53
+ it('falls back to a plain inner <svg> when the class svg is absent', () => {
54
+ const wrap = document.createElement('div')
55
+ const svg = document.createElementNS(SVG_NS, 'svg') as SVGSVGElement
56
+ svg.setAttribute('viewBox', '0 0 100 100')
57
+ wrap.appendChild(svg)
58
+ const out = chartToSvgString(wrap)
59
+ expect(out).toContain('<svg')
60
+ })
61
+
62
+ it('throws when no <svg> can be found', () => {
63
+ const div = document.createElement('div')
64
+ expect(() => chartToSvgString(div)).toThrow(/no <svg> found/)
65
+ })
66
+
67
+ it('inlines a <style> block with concrete theme colors', () => {
68
+ const out = chartToSvgString(makeChartSvg())
69
+ expect(out).toContain('<style')
70
+ expect(out).toContain('.sv-grid-chart-axis')
71
+ expect(out).toContain('.sv-grid-chart-datalabel')
72
+ expect(out).toContain('.sv-grid-chart-donut-total')
73
+ // default fallbacks used when no --sg-* vars are set in jsdom
74
+ expect(out).toContain('#0f172a') // --sg-fg fallback
75
+ expect(out).toContain('#64748b') // --sg-muted fallback
76
+ expect(out).toContain('#e2e8f0') // --sg-border fallback
77
+ })
78
+
79
+ it('strips interaction-only hit layers from the export', () => {
80
+ const out = chartToSvgString(makeChartSvg())
81
+ expect(out).not.toContain('sv-grid-chart-hit')
82
+ // but keeps the real content
83
+ expect(out).toContain('sv-grid-chart-bar')
84
+ })
85
+
86
+ it('sets width/height from the viewBox and inserts a background rect', () => {
87
+ const out = chartToSvgString(makeChartSvg({ viewBox: '0 0 400 250' }))
88
+ expect(out).toContain('width="400"')
89
+ expect(out).toContain('height="250"')
90
+ // background rect spans the full size with the default --sg-bg fallback
91
+ expect(out).toMatch(/<rect[^>]*fill="#ffffff"/)
92
+ })
93
+
94
+ it('honors a custom background option over the theme default', () => {
95
+ const out = chartToSvgString(makeChartSvg(), { background: 'tomato' })
96
+ expect(out).toMatch(/<rect[^>]*fill="tomato"/)
97
+ expect(out).not.toContain('fill="#ffffff"')
98
+ })
99
+
100
+ it('falls back to client size / defaults when no usable viewBox', () => {
101
+ const svg = document.createElementNS(SVG_NS, 'svg') as SVGSVGElement
102
+ svg.setAttribute('class', 'sv-grid-chart-svg')
103
+ // No viewBox attribute => baseVal width 0; jsdom clientWidth is 0 too,
104
+ // so the exporter uses its 520x300 hard fallback.
105
+ const out = chartToSvgString(svg)
106
+ expect(out).toContain('width="520"')
107
+ expect(out).toContain('height="300"')
108
+ })
109
+
110
+ it('reads --sg-* css variables when present on the element', () => {
111
+ const svg = makeChartSvg()
112
+ svg.style.setProperty('--sg-fg', '#112233')
113
+ svg.style.setProperty('--sg-muted', '#445566')
114
+ document.body.appendChild(svg)
115
+ try {
116
+ const out = chartToSvgString(svg)
117
+ expect(out).toContain('#112233')
118
+ expect(out).toContain('#445566')
119
+ } finally {
120
+ svg.remove()
121
+ }
122
+ })
123
+ })
124
+
125
+ describe('downloadChartSvg', () => {
126
+ let createSpy: ReturnType<typeof vi.spyOn>
127
+ let revokeSpy: ReturnType<typeof vi.spyOn>
128
+ let clickSpy: ReturnType<typeof vi.fn>
129
+
130
+ beforeEach(() => {
131
+ vi.useFakeTimers()
132
+ createSpy = vi
133
+ .spyOn(URL, 'createObjectURL')
134
+ .mockReturnValue('blob:fake-url')
135
+ revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
136
+ clickSpy = vi.fn()
137
+ // jsdom anchors don't navigate; stub click to observe the download.
138
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickSpy)
139
+ })
140
+
141
+ afterEach(() => {
142
+ vi.useRealTimers()
143
+ vi.restoreAllMocks()
144
+ })
145
+
146
+ it('creates a blob url, triggers an anchor download, then revokes', () => {
147
+ downloadChartSvg(makeChartSvg(), 'my-chart.svg')
148
+
149
+ expect(createSpy).toHaveBeenCalledTimes(1)
150
+ const blob = createSpy.mock.calls[0]![0] as Blob
151
+ expect(blob).toBeInstanceOf(Blob)
152
+ expect(blob.type).toBe('image/svg+xml')
153
+ expect(clickSpy).toHaveBeenCalledTimes(1)
154
+
155
+ // revoke is scheduled on a timer
156
+ expect(revokeSpy).not.toHaveBeenCalled()
157
+ vi.advanceTimersByTime(1000)
158
+ expect(revokeSpy).toHaveBeenCalledWith('blob:fake-url')
159
+ })
160
+
161
+ it('removes the temporary anchor from the DOM after clicking', () => {
162
+ const before = document.querySelectorAll('a').length
163
+ downloadChartSvg(makeChartSvg())
164
+ expect(document.querySelectorAll('a').length).toBe(before)
165
+ })
166
+
167
+ it('uses the default filename when none is given', () => {
168
+ let captured: string | undefined
169
+ vi.spyOn(HTMLAnchorElement.prototype, 'download', 'set').mockImplementation(
170
+ function (this: HTMLAnchorElement, v: string) {
171
+ captured = v
172
+ },
173
+ )
174
+ downloadChartSvg(makeChartSvg())
175
+ expect(captured).toBe('chart.svg')
176
+ })
177
+ })
178
+
179
+ describe('chartToPngBlob', () => {
180
+ afterEach(() => {
181
+ vi.restoreAllMocks()
182
+ })
183
+
184
+ function stubImage(behavior: 'load' | 'error') {
185
+ // jsdom's Image never fires load/error for a blob: src, so replace it
186
+ // with a controllable fake that triggers the chosen callback.
187
+ class FakeImage {
188
+ onload: (() => void) | null = null
189
+ onerror: (() => void) | null = null
190
+ private _src = ''
191
+ set src(_v: string) {
192
+ this._src = _v
193
+ queueMicrotask(() => {
194
+ if (behavior === 'load') this.onload?.()
195
+ else this.onerror?.()
196
+ })
197
+ }
198
+ get src() {
199
+ return this._src
200
+ }
201
+ }
202
+ vi.stubGlobal('Image', FakeImage)
203
+ }
204
+
205
+ it('rejects when the SVG image fails to rasterize', async () => {
206
+ stubImage('error')
207
+ vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:x')
208
+ const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
209
+
210
+ await expect(chartToPngBlob(makeChartSvg())).rejects.toThrow(
211
+ /failed to rasterize/,
212
+ )
213
+ expect(revoke).toHaveBeenCalledWith('blob:x')
214
+ })
215
+
216
+ it('rejects when a 2d canvas context is unavailable', async () => {
217
+ stubImage('load')
218
+ vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:x')
219
+ vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
220
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
221
+ null as never,
222
+ )
223
+
224
+ await expect(chartToPngBlob(makeChartSvg())).rejects.toThrow(
225
+ /canvas 2d context unavailable/,
226
+ )
227
+ })
228
+
229
+ it('resolves a PNG blob once drawImage + toBlob succeed', async () => {
230
+ stubImage('load')
231
+ vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:x')
232
+ vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
233
+
234
+ const drawImage = vi.fn()
235
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
236
+ drawImage,
237
+ } as never)
238
+ vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(function (
239
+ this: HTMLCanvasElement,
240
+ cb: BlobCallback,
241
+ ) {
242
+ cb(new Blob(['png-bytes'], { type: 'image/png' }))
243
+ } as never)
244
+
245
+ const blob = await chartToPngBlob(makeChartSvg({ viewBox: '0 0 400 250' }), {
246
+ scale: 3,
247
+ })
248
+ expect(blob).toBeInstanceOf(Blob)
249
+ expect(blob.type).toBe('image/png')
250
+ expect(drawImage).toHaveBeenCalledTimes(1)
251
+ })
252
+
253
+ it('rejects when toBlob yields null', async () => {
254
+ stubImage('load')
255
+ vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:x')
256
+ vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
257
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
258
+ drawImage: vi.fn(),
259
+ } as never)
260
+ vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(function (
261
+ this: HTMLCanvasElement,
262
+ cb: BlobCallback,
263
+ ) {
264
+ cb(null)
265
+ } as never)
266
+
267
+ await expect(chartToPngBlob(makeChartSvg())).rejects.toThrow(/toBlob failed/)
268
+ })
269
+
270
+ it('scales the canvas by options.scale (default 2)', async () => {
271
+ stubImage('load')
272
+ vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:x')
273
+ vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
274
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
275
+ drawImage: vi.fn(),
276
+ } as never)
277
+
278
+ let canvasW = -1
279
+ let canvasH = -1
280
+ vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(function (
281
+ this: HTMLCanvasElement,
282
+ cb: BlobCallback,
283
+ ) {
284
+ canvasW = this.width
285
+ canvasH = this.height
286
+ cb(new Blob([], { type: 'image/png' }))
287
+ } as never)
288
+
289
+ // viewBox 400x250, default scale 2 => 800x500
290
+ await chartToPngBlob(makeChartSvg({ viewBox: '0 0 400 250' }))
291
+ expect(canvasW).toBe(800)
292
+ expect(canvasH).toBe(500)
293
+ })
294
+
295
+ it('falls back to the 520x300 default size when there is no viewBox', async () => {
296
+ stubImage('load')
297
+ vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:x')
298
+ vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
299
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
300
+ drawImage: vi.fn(),
301
+ } as never)
302
+
303
+ let canvasW = -1
304
+ let canvasH = -1
305
+ vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(function (
306
+ this: HTMLCanvasElement,
307
+ cb: BlobCallback,
308
+ ) {
309
+ canvasW = this.width
310
+ canvasH = this.height
311
+ cb(new Blob([], { type: 'image/png' }))
312
+ } as never)
313
+
314
+ // No viewBox + jsdom clientWidth 0 => 520x300 hard fallback, scale 2.
315
+ const noVb = document.createElementNS(SVG_NS, 'svg') as SVGSVGElement
316
+ noVb.setAttribute('class', 'sv-grid-chart-svg')
317
+ await chartToPngBlob(noVb)
318
+ expect(canvasW).toBe(1040)
319
+ expect(canvasH).toBe(600)
320
+ })
321
+ })
322
+
323
+ describe('downloadChartPng', () => {
324
+ afterEach(() => {
325
+ vi.useRealTimers()
326
+ vi.restoreAllMocks()
327
+ })
328
+
329
+ it('rasterizes then downloads via an anchor and revokes the url', async () => {
330
+ vi.useFakeTimers()
331
+ // Make chartToPngBlob succeed.
332
+ class FakeImage {
333
+ onload: (() => void) | null = null
334
+ onerror: (() => void) | null = null
335
+ set src(_v: string) {
336
+ queueMicrotask(() => this.onload?.())
337
+ }
338
+ }
339
+ vi.stubGlobal('Image', FakeImage)
340
+ vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:png-url')
341
+ const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
342
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
343
+ drawImage: vi.fn(),
344
+ } as never)
345
+ vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(function (
346
+ this: HTMLCanvasElement,
347
+ cb: BlobCallback,
348
+ ) {
349
+ cb(new Blob(['p'], { type: 'image/png' }))
350
+ } as never)
351
+ const click = vi.fn()
352
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(click)
353
+ let filename: string | undefined
354
+ vi.spyOn(HTMLAnchorElement.prototype, 'download', 'set').mockImplementation(
355
+ function (this: HTMLAnchorElement, v: string) {
356
+ filename = v
357
+ },
358
+ )
359
+
360
+ const p = downloadChartPng(makeChartSvg(), 'pic.png')
361
+ // flush the microtask-driven image onload + promise chain
362
+ await vi.runAllTimersAsync()
363
+ await p
364
+
365
+ expect(click).toHaveBeenCalledTimes(1)
366
+ expect(filename).toBe('pic.png')
367
+ vi.advanceTimersByTime(1000)
368
+ expect(revoke).toHaveBeenCalledWith('blob:png-url')
369
+ })
370
+ })