@morscherlab/mint-sdk 1.0.56 → 1.0.58

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 (44) hide show
  1. package/dist/__tests__/components/ArtifactSaveDialog.test.d.ts +1 -0
  2. package/dist/__tests__/components/ArtifactSelectorModal.test.d.ts +1 -0
  3. package/dist/__tests__/composables/useAnalysisArtifacts.test.d.ts +1 -0
  4. package/dist/analysisArtifactTypes-Cu40dzSG.js +7 -0
  5. package/dist/analysisArtifactTypes-Cu40dzSG.js.map +1 -0
  6. package/dist/components/ArtifactSaveDialog.vue.d.ts +59 -0
  7. package/dist/components/ArtifactSelectorModal.vue.d.ts +41 -0
  8. package/dist/components/index.d.ts +2 -0
  9. package/dist/components/index.js +2 -2
  10. package/dist/{components-BQwOeyiy.js → components-CpnOaPbP.js} +1774 -1144
  11. package/dist/components-CpnOaPbP.js.map +1 -0
  12. package/dist/composables/index.d.ts +1 -0
  13. package/dist/composables/index.js +4 -3
  14. package/dist/composables/useAnalysisArtifacts.d.ts +59 -0
  15. package/dist/{composables-TpXyhRZZ.js → composables-OYTD0Y3r.js} +2 -2
  16. package/dist/{composables-TpXyhRZZ.js.map → composables-OYTD0Y3r.js.map} +1 -1
  17. package/dist/index.js +5 -4
  18. package/dist/index.js.map +1 -1
  19. package/dist/install.js +1 -1
  20. package/dist/styles.css +412 -0
  21. package/dist/types/analysisArtifactTypes.d.ts +133 -0
  22. package/dist/types/index.d.ts +2 -0
  23. package/dist/types/index.js +2 -0
  24. package/dist/{useJobsStatusTray-CVecN6Ao.js → useAnalysisArtifacts-Ob8UtVwl.js} +157 -2
  25. package/dist/useAnalysisArtifacts-Ob8UtVwl.js.map +1 -0
  26. package/package.json +1 -1
  27. package/src/__tests__/components/ArtifactSaveDialog.test.ts +566 -0
  28. package/src/__tests__/components/ArtifactSelectorModal.test.ts +178 -0
  29. package/src/__tests__/composables/useAnalysisArtifacts.test.ts +667 -0
  30. package/src/components/ArtifactSaveDialog.story.vue +319 -0
  31. package/src/components/ArtifactSaveDialog.vue +434 -0
  32. package/src/components/ArtifactSelectorModal.story.vue +295 -0
  33. package/src/components/ArtifactSelectorModal.vue +326 -0
  34. package/src/components/index.ts +2 -0
  35. package/src/composables/index.ts +6 -0
  36. package/src/composables/useAnalysisArtifacts.ts +305 -0
  37. package/src/styles/components/artifact-save-dialog.css +17 -0
  38. package/src/styles/components/artifact-selector-modal.css +216 -0
  39. package/src/styles/index.css +2 -0
  40. package/src/types/analysisArtifactTypes.ts +167 -0
  41. package/src/types/index.ts +24 -0
  42. package/dist/components-BQwOeyiy.js.map +0 -1
  43. package/dist/useJobsStatusTray-CVecN6Ao.js.map +0 -1
  44. /package/dist/{ExperimentPopover-8A4Rhffp.js → ExperimentPopover-Ryusjrhv.js} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.0.56",
3
+ "version": "1.0.58",
4
4
  "description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,566 @@
1
+ import { mount, flushPromises } from '@vue/test-utils'
2
+ import { describe, it, expect, expectTypeOf, vi, beforeEach } from 'vitest'
3
+ import ArtifactSaveDialog from '../../components/ArtifactSaveDialog.vue'
4
+ import FileUploader from '../../components/FileUploader.vue'
5
+ import SegmentedControl from '../../components/SegmentedControl.vue'
6
+ import { ANALYSIS_FILE_ARTIFACT_SCHEMA } from '../../types/analysisArtifactTypes'
7
+ import type {
8
+ AnalysisArtifactDetail,
9
+ ArtifactSaveDialogPayload,
10
+ ArtifactSavePayload,
11
+ ArtifactSaveResult,
12
+ } from '../../types/analysisArtifactTypes'
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function makeDetail(overrides: Partial<AnalysisArtifactDetail> = {}): AnalysisArtifactDetail {
19
+ return {
20
+ id: 5,
21
+ experiment_id: 42,
22
+ plugin_id: 'plugin-a',
23
+ artifact_key: 'fit',
24
+ display_name: 'Dose Response Fit',
25
+ note: 'baseline run',
26
+ status: 'active',
27
+ result_keys: ['summary'],
28
+ result: { curves: [] },
29
+ tree: [],
30
+ summary: null,
31
+ ...overrides,
32
+ }
33
+ }
34
+
35
+ function makeFileDetail(overrides: Partial<AnalysisArtifactDetail> = {}): AnalysisArtifactDetail {
36
+ return makeDetail({
37
+ artifact_key: 'report',
38
+ display_name: 'QC Report',
39
+ result: {
40
+ schema_version: ANALYSIS_FILE_ARTIFACT_SCHEMA,
41
+ kind: 'report',
42
+ filename: 'qc-report.pdf',
43
+ file: {},
44
+ },
45
+ ...overrides,
46
+ })
47
+ }
48
+
49
+ function mountDialog(props: Record<string, unknown> = {}) {
50
+ const saveHandler = vi.fn<(payload: ArtifactSaveDialogPayload) => Promise<ArtifactSaveResult | void>>()
51
+ .mockResolvedValue(undefined)
52
+ const wrapper = mount(ArtifactSaveDialog, {
53
+ props: {
54
+ modelValue: true,
55
+ experimentId: 42,
56
+ saveHandler,
57
+ ...props,
58
+ },
59
+ global: { stubs: { teleport: true } },
60
+ })
61
+ return { wrapper, saveHandler }
62
+ }
63
+
64
+ async function submit(wrapper: ReturnType<typeof mountDialog>['wrapper']) {
65
+ await wrapper.get('.mint-artifact-save-dialog__submit').trigger('click')
66
+ await flushPromises()
67
+ }
68
+
69
+ const TEST_FILE = new File(['content'], 'plot.png', { type: 'image/png' })
70
+
71
+ async function chooseFile(wrapper: ReturnType<typeof mountDialog>['wrapper'], file = TEST_FILE) {
72
+ wrapper.findComponent(FileUploader).vm.$emit('upload', [file])
73
+ await flushPromises()
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Tests
78
+ // ---------------------------------------------------------------------------
79
+
80
+ describe('ArtifactSaveDialog', () => {
81
+ beforeEach(() => {
82
+ delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__
83
+ window.history.replaceState({}, '', '/')
84
+ })
85
+
86
+ it('should keep browser File required in the existing upload handler contract', () => {
87
+ type UploadFilePayload = Extract<ArtifactSavePayload, { kind: 'file' }>
88
+
89
+ expectTypeOf<UploadFilePayload['file']>().toEqualTypeOf<File>()
90
+ })
91
+
92
+ // -------------------------------------------------------------------------
93
+ // JSON create
94
+ // -------------------------------------------------------------------------
95
+
96
+ describe('json create', () => {
97
+ it('should call the saveHandler once with a create payload', async () => {
98
+ const { wrapper, saveHandler } = mountDialog({ mode: 'json', result: { x: 1 } })
99
+
100
+ await submit(wrapper)
101
+
102
+ expect(saveHandler).toHaveBeenCalledOnce()
103
+ expect(saveHandler).toHaveBeenCalledWith({
104
+ kind: 'json',
105
+ mode: 'create',
106
+ experimentId: 42,
107
+ artifactKey: 'default',
108
+ result: { x: 1 },
109
+ })
110
+ })
111
+
112
+ it('should use the typed artifact key and display name', async () => {
113
+ const { wrapper, saveHandler } = mountDialog({ mode: 'json', result: { x: 1 } })
114
+
115
+ await wrapper.get('.mint-artifact-save-dialog__key input').setValue('my-fit')
116
+ await wrapper.get('.mint-artifact-save-dialog__name input').setValue('My Fit')
117
+ await submit(wrapper)
118
+
119
+ const payload = saveHandler.mock.calls[0][0]
120
+ expect(payload.artifactKey).toBe('my-fit')
121
+ expect(payload).toMatchObject({ displayName: 'My Fit' })
122
+ })
123
+
124
+ it('should include a typed note as a string', async () => {
125
+ const { wrapper, saveHandler } = mountDialog({ mode: 'json', result: { x: 1 } })
126
+
127
+ await wrapper.get('.mint-artifact-save-dialog__note textarea').setValue('first pass')
128
+ await submit(wrapper)
129
+
130
+ expect(saveHandler.mock.calls[0][0].note).toBe('first pass')
131
+ })
132
+
133
+ it('should emit saved with the payload and close on success', async () => {
134
+ const { wrapper, saveHandler } = mountDialog({ mode: 'json', result: { x: 1 } })
135
+ saveHandler.mockResolvedValueOnce({ cleanupPending: false })
136
+
137
+ await submit(wrapper)
138
+
139
+ const saved = wrapper.emitted('saved')
140
+ expect(saved).toHaveLength(1)
141
+ expect((saved![0][0] as { payload: ArtifactSaveDialogPayload }).payload.kind).toBe('json')
142
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false])
143
+ })
144
+
145
+ it('should disable submit when no result payload is provided', async () => {
146
+ const { wrapper, saveHandler } = mountDialog({ mode: 'json' })
147
+
148
+ await submit(wrapper)
149
+
150
+ expect(saveHandler).not.toHaveBeenCalled()
151
+ })
152
+ })
153
+
154
+ // -------------------------------------------------------------------------
155
+ // Key validation
156
+ // -------------------------------------------------------------------------
157
+
158
+ describe('artifact key validation', () => {
159
+ it('should block submit on an invalid key', async () => {
160
+ const { wrapper, saveHandler } = mountDialog({ mode: 'json', result: { x: 1 } })
161
+
162
+ await wrapper.get('.mint-artifact-save-dialog__key input').setValue('Bad Key!')
163
+ await submit(wrapper)
164
+
165
+ expect(saveHandler).not.toHaveBeenCalled()
166
+ })
167
+
168
+ it('should fall back to the default key when the input is emptied', async () => {
169
+ const { wrapper, saveHandler } = mountDialog({
170
+ mode: 'json',
171
+ result: { x: 1 },
172
+ defaultArtifactKey: 'fallback-key',
173
+ })
174
+
175
+ await wrapper.get('.mint-artifact-save-dialog__key input').setValue('')
176
+ await submit(wrapper)
177
+
178
+ expect(saveHandler.mock.calls[0][0].artifactKey).toBe('fallback-key')
179
+ })
180
+ })
181
+
182
+ // -------------------------------------------------------------------------
183
+ // File create
184
+ // -------------------------------------------------------------------------
185
+
186
+ describe('file create', () => {
187
+ it('should block submit until a file is chosen', async () => {
188
+ const { wrapper, saveHandler } = mountDialog({ mode: 'file' })
189
+
190
+ await submit(wrapper)
191
+
192
+ expect(saveHandler).not.toHaveBeenCalled()
193
+ })
194
+
195
+ it('should send a file create payload with the chosen file', async () => {
196
+ const { wrapper, saveHandler } = mountDialog({ mode: 'file' })
197
+
198
+ await chooseFile(wrapper)
199
+ await submit(wrapper)
200
+
201
+ expect(saveHandler).toHaveBeenCalledOnce()
202
+ const payload = saveHandler.mock.calls[0][0]
203
+ expect(payload).toEqual({
204
+ kind: 'file',
205
+ mode: 'create',
206
+ experimentId: 42,
207
+ artifactKey: 'default',
208
+ file: TEST_FILE,
209
+ fileKind: 'file',
210
+ filename: 'plot.png',
211
+ contentType: 'image/png',
212
+ })
213
+ })
214
+
215
+ it('should use the defaultFileKind prop', async () => {
216
+ const { wrapper, saveHandler } = mountDialog({ mode: 'file', defaultFileKind: 'plot' })
217
+
218
+ await chooseFile(wrapper)
219
+ await submit(wrapper)
220
+
221
+ expect((saveHandler.mock.calls[0][0] as { fileKind: string }).fileKind).toBe('plot')
222
+ })
223
+
224
+ it('should hide the uploader when file content is owned by the handler', () => {
225
+ const { wrapper } = mountDialog({ mode: 'file', fileSource: 'handler' })
226
+
227
+ expect(wrapper.findComponent(FileUploader).exists()).toBe(false)
228
+ })
229
+
230
+ it('should submit a handler-owned file create without a browser File', async () => {
231
+ const { wrapper, saveHandler } = mountDialog({ mode: 'file', fileSource: 'handler' })
232
+
233
+ await submit(wrapper)
234
+
235
+ expect(saveHandler.mock.calls[0][0]).toEqual({
236
+ kind: 'file',
237
+ source: 'handler',
238
+ mode: 'create',
239
+ experimentId: 42,
240
+ artifactKey: 'default',
241
+ fileKind: 'file',
242
+ })
243
+ })
244
+ })
245
+
246
+ // -------------------------------------------------------------------------
247
+ // Both mode
248
+ // -------------------------------------------------------------------------
249
+
250
+ describe('both mode', () => {
251
+ it('should default to the file variant and switch to json via the kind toggle', async () => {
252
+ const { wrapper, saveHandler } = mountDialog({ mode: 'both', result: { x: 1 } })
253
+
254
+ expect(wrapper.findComponent(SegmentedControl).exists()).toBe(true)
255
+ expect(wrapper.findComponent(FileUploader).exists()).toBe(true)
256
+
257
+ wrapper.findComponent(SegmentedControl).vm.$emit('update:modelValue', 'json')
258
+ await flushPromises()
259
+ await submit(wrapper)
260
+
261
+ expect(saveHandler.mock.calls[0][0].kind).toBe('json')
262
+ })
263
+ })
264
+
265
+ // -------------------------------------------------------------------------
266
+ // Key collisions
267
+ // -------------------------------------------------------------------------
268
+
269
+ describe('key collisions', () => {
270
+ // The backend upserts a JSON artifact by key, but only sets display_name/note
271
+ // when provided — the warning must say so rather than promise a clean replace.
272
+ it('should warn and send upsert for a json save when the key exists', async () => {
273
+ const { wrapper, saveHandler } = mountDialog({
274
+ mode: 'json',
275
+ result: { x: 1 },
276
+ existingArtifactKeys: ['default'],
277
+ })
278
+
279
+ expect(wrapper.get('.mint-artifact-save-dialog__warning').text()).toContain('kept unless')
280
+ await submit(wrapper)
281
+
282
+ expect(saveHandler.mock.calls[0][0].mode).toBe('upsert')
283
+ })
284
+
285
+ // save_analysis_file_artifact is create-only: a colliding key always raises
286
+ // ConflictException, and the dialog lacks the detail needed to replace safely.
287
+ it('should block a file save when the key exists', async () => {
288
+ const { wrapper, saveHandler } = mountDialog({
289
+ mode: 'file',
290
+ existingArtifactKeys: ['default'],
291
+ })
292
+
293
+ await chooseFile(wrapper)
294
+ await submit(wrapper)
295
+
296
+ expect(saveHandler).not.toHaveBeenCalled()
297
+ expect(wrapper.get('.mint-artifact-save-dialog__key').text()).toContain('already used')
298
+ })
299
+
300
+ it('should not warn when the key is unique', async () => {
301
+ const { wrapper } = mountDialog({
302
+ mode: 'json',
303
+ result: { x: 1 },
304
+ existingArtifactKeys: ['other'],
305
+ })
306
+
307
+ expect(wrapper.find('.mint-artifact-save-dialog__warning').exists()).toBe(false)
308
+ })
309
+ })
310
+
311
+ // -------------------------------------------------------------------------
312
+ // Update mode (existingArtifact)
313
+ // -------------------------------------------------------------------------
314
+
315
+ describe('update mode', () => {
316
+ it('should lock the artifact key to the existing artifact', async () => {
317
+ const { wrapper } = mountDialog({ result: { x: 1 }, existingArtifact: makeDetail() })
318
+
319
+ const keyInput = wrapper.get('.mint-artifact-save-dialog__key input')
320
+ expect((keyInput.element as HTMLInputElement).value).toBe('fit')
321
+ expect(keyInput.attributes('readonly')).toBeDefined()
322
+ })
323
+
324
+ it('should upsert a json artifact preserving its display name', async () => {
325
+ const { wrapper, saveHandler } = mountDialog({
326
+ result: { x: 2 },
327
+ existingArtifact: makeDetail(),
328
+ })
329
+
330
+ await submit(wrapper)
331
+
332
+ expect(saveHandler.mock.calls[0][0]).toMatchObject({
333
+ kind: 'json',
334
+ mode: 'upsert',
335
+ artifactKey: 'fit',
336
+ displayName: 'Dose Response Fit',
337
+ result: { x: 2 },
338
+ })
339
+ })
340
+
341
+ // save_analysis_artifact ignores note=None (preserves), so it cannot clear a
342
+ // note; offering the field here would silently discard the user's edit.
343
+ it('should hide the note field when updating a json artifact', async () => {
344
+ const { wrapper } = mountDialog({ result: { x: 2 }, existingArtifact: makeDetail() })
345
+
346
+ expect(wrapper.find('.mint-artifact-save-dialog__note').exists()).toBe(false)
347
+ })
348
+
349
+ it('should never send a note on a json upsert', async () => {
350
+ const { wrapper, saveHandler } = mountDialog({
351
+ result: { x: 2 },
352
+ existingArtifact: makeDetail({ note: 'baseline run' }),
353
+ })
354
+
355
+ await submit(wrapper)
356
+
357
+ expect('note' in saveHandler.mock.calls[0][0]).toBe(false)
358
+ })
359
+
360
+ it('should keep the note field for file artifacts, which support tri-state notes', async () => {
361
+ const { wrapper } = mountDialog({ existingArtifact: makeFileDetail() })
362
+
363
+ expect(wrapper.find('.mint-artifact-save-dialog__note').exists()).toBe(true)
364
+ })
365
+
366
+ it('should send update-file payload with the immutable file kind', async () => {
367
+ const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail() })
368
+
369
+ await chooseFile(wrapper)
370
+ await submit(wrapper)
371
+
372
+ expect(saveHandler.mock.calls[0][0]).toMatchObject({
373
+ kind: 'file',
374
+ mode: 'update',
375
+ artifactKey: 'report',
376
+ fileKind: 'report',
377
+ })
378
+ })
379
+
380
+ // update_analysis_file_artifact REPLACES result metadata rather than merging,
381
+ // so omitting it would wipe whatever the artifact already carried.
382
+ it('should carry the existing result metadata through a file replacement', async () => {
383
+ const { wrapper, saveHandler } = mountDialog({
384
+ existingArtifact: makeFileDetail({
385
+ result: {
386
+ schema_version: ANALYSIS_FILE_ARTIFACT_SCHEMA,
387
+ kind: 'report',
388
+ filename: 'qc-report.pdf',
389
+ file: {},
390
+ metadata: { instrument: 'QE-HF', replicate: 2, passed: true },
391
+ },
392
+ }),
393
+ })
394
+
395
+ await chooseFile(wrapper)
396
+ await submit(wrapper)
397
+
398
+ expect(saveHandler.mock.calls[0][0]).toMatchObject({
399
+ metadata: { instrument: 'QE-HF', replicate: 2, passed: true },
400
+ })
401
+ })
402
+
403
+ it('should omit metadata when the artifact has none', async () => {
404
+ const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail() })
405
+
406
+ await chooseFile(wrapper)
407
+ await submit(wrapper)
408
+
409
+ expect('metadata' in saveHandler.mock.calls[0][0]).toBe(false)
410
+ })
411
+
412
+ it('should let a metadata prop override the carried-through metadata', async () => {
413
+ const { wrapper, saveHandler } = mountDialog({
414
+ existingArtifact: makeFileDetail({
415
+ result: {
416
+ schema_version: ANALYSIS_FILE_ARTIFACT_SCHEMA,
417
+ kind: 'report',
418
+ filename: 'qc-report.pdf',
419
+ file: {},
420
+ metadata: { stale: true },
421
+ },
422
+ }),
423
+ metadata: { fresh: 'yes' },
424
+ })
425
+
426
+ await chooseFile(wrapper)
427
+ await submit(wrapper)
428
+
429
+ expect(saveHandler.mock.calls[0][0]).toMatchObject({ metadata: { fresh: 'yes' } })
430
+ })
431
+
432
+ it('should require a replacement file for file artifacts', async () => {
433
+ const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail() })
434
+
435
+ await submit(wrapper)
436
+
437
+ expect(saveHandler).not.toHaveBeenCalled()
438
+ })
439
+
440
+ it('should omit note when untouched', async () => {
441
+ const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail() })
442
+
443
+ await chooseFile(wrapper)
444
+ await submit(wrapper)
445
+
446
+ expect('note' in saveHandler.mock.calls[0][0]).toBe(false)
447
+ })
448
+
449
+ it('should send null when a previously set note is cleared', async () => {
450
+ const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail() })
451
+
452
+ await wrapper.get('.mint-artifact-save-dialog__note textarea').setValue('')
453
+ await chooseFile(wrapper)
454
+ await submit(wrapper)
455
+
456
+ expect(saveHandler.mock.calls[0][0].note).toBeNull()
457
+ })
458
+
459
+ it('should send the edited note as a string', async () => {
460
+ const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail() })
461
+
462
+ await wrapper.get('.mint-artifact-save-dialog__note textarea').setValue('re-measured')
463
+ await chooseFile(wrapper)
464
+ await submit(wrapper)
465
+
466
+ expect(saveHandler.mock.calls[0][0].note).toBe('re-measured')
467
+ })
468
+
469
+ it('should submit a handler-owned file update without a browser File', async () => {
470
+ const { wrapper, saveHandler } = mountDialog({
471
+ existingArtifact: makeFileDetail(),
472
+ fileSource: 'handler',
473
+ })
474
+ await wrapper.get('.mint-artifact-save-dialog__note textarea').setValue('')
475
+
476
+ await submit(wrapper)
477
+
478
+ expect(saveHandler.mock.calls[0][0]).toEqual({
479
+ kind: 'file',
480
+ source: 'handler',
481
+ mode: 'update',
482
+ experimentId: 42,
483
+ artifactKey: 'report',
484
+ fileKind: 'report',
485
+ note: null,
486
+ })
487
+ })
488
+ })
489
+
490
+ // -------------------------------------------------------------------------
491
+ // Form lifecycle
492
+ // -------------------------------------------------------------------------
493
+
494
+ describe('form lifecycle', () => {
495
+ it('should keep in-progress edits when the same artifact is re-fetched', async () => {
496
+ const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail() })
497
+ await wrapper.get('.mint-artifact-save-dialog__note textarea').setValue('mid-edit note')
498
+
499
+ // A host poll hands back an equal-but-new detail object.
500
+ await wrapper.setProps({ existingArtifact: makeFileDetail() })
501
+
502
+ expect((wrapper.get('.mint-artifact-save-dialog__note textarea').element as HTMLTextAreaElement).value)
503
+ .toBe('mid-edit note')
504
+
505
+ await chooseFile(wrapper)
506
+ await submit(wrapper)
507
+ expect(saveHandler.mock.calls[0][0].note).toBe('mid-edit note')
508
+ })
509
+
510
+ it('should reset the form when a different artifact is supplied', async () => {
511
+ const { wrapper } = mountDialog({ existingArtifact: makeFileDetail() })
512
+ await wrapper.get('.mint-artifact-save-dialog__note textarea').setValue('stale edit')
513
+
514
+ await wrapper.setProps({
515
+ existingArtifact: makeFileDetail({ id: 9, artifact_key: 'other-report', note: 'fresh' }),
516
+ })
517
+
518
+ expect((wrapper.get('.mint-artifact-save-dialog__key input').element as HTMLInputElement).value)
519
+ .toBe('other-report')
520
+ expect((wrapper.get('.mint-artifact-save-dialog__note textarea').element as HTMLTextAreaElement).value)
521
+ .toBe('fresh')
522
+ })
523
+ })
524
+
525
+ // -------------------------------------------------------------------------
526
+ // Error handling
527
+ // -------------------------------------------------------------------------
528
+
529
+ describe('error handling', () => {
530
+ it('should stay open, render the error, and emit error when the handler rejects', async () => {
531
+ const { wrapper, saveHandler } = mountDialog({ mode: 'json', result: { x: 1 } })
532
+ saveHandler.mockRejectedValueOnce(new Error('Backend unavailable'))
533
+
534
+ await submit(wrapper)
535
+
536
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
537
+ expect(wrapper.emitted('error')).toEqual([['Backend unavailable']])
538
+ expect(wrapper.get('.mint-artifact-save-dialog__error').text()).toContain('Backend unavailable')
539
+ expect(wrapper.emitted('saved')).toBeUndefined()
540
+ })
541
+
542
+ it('should surface cleanupPending from the handler result in saved', async () => {
543
+ const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail() })
544
+ saveHandler.mockResolvedValueOnce({ cleanupPending: true })
545
+
546
+ await chooseFile(wrapper)
547
+ await submit(wrapper)
548
+
549
+ const saved = wrapper.emitted('saved')![0][0] as { cleanupPending?: boolean }
550
+ expect(saved.cleanupPending).toBe(true)
551
+ })
552
+
553
+ it('should set an error when no experiment id can be resolved', async () => {
554
+ const { wrapper, saveHandler } = mountDialog({
555
+ mode: 'json',
556
+ result: { x: 1 },
557
+ experimentId: null,
558
+ })
559
+
560
+ await submit(wrapper)
561
+
562
+ expect(saveHandler).not.toHaveBeenCalled()
563
+ expect(wrapper.find('.mint-artifact-save-dialog__error').exists()).toBe(true)
564
+ })
565
+ })
566
+ })