import { mount, flushPromises } from '@vue/test-utils' import { describe, it, expect, expectTypeOf, vi, beforeEach } from 'vitest' import ArtifactSaveDialog from '../../components/ArtifactSaveDialog.vue' import FileUploader from '../../components/FileUploader.vue' import SegmentedControl from '../../components/SegmentedControl.vue' import { ANALYSIS_FILE_ARTIFACT_SCHEMA } from '../../types/analysisArtifactTypes' import type { AnalysisArtifactDetail, ArtifactSaveDialogPayload, ArtifactSavePayload, ArtifactSaveResult, } from '../../types/analysisArtifactTypes' // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeDetail(overrides: Partial = {}): AnalysisArtifactDetail { return { id: 5, experiment_id: 42, plugin_id: 'plugin-a', artifact_key: 'fit', display_name: 'Dose Response Fit', note: 'baseline run', status: 'active', result_keys: ['summary'], result: { curves: [] }, tree: [], summary: null, ...overrides, } } function makeFileDetail(overrides: Partial = {}): AnalysisArtifactDetail { return makeDetail({ artifact_key: 'report', display_name: 'QC Report', result: { schema_version: ANALYSIS_FILE_ARTIFACT_SCHEMA, kind: 'report', filename: 'qc-report.pdf', file: {}, }, ...overrides, }) } function mountDialog(props: Record = {}) { const saveHandler = vi.fn<(payload: ArtifactSaveDialogPayload) => Promise>() .mockResolvedValue(undefined) const wrapper = mount(ArtifactSaveDialog, { props: { modelValue: true, experimentId: 42, saveHandler, ...props, }, global: { stubs: { teleport: true } }, }) return { wrapper, saveHandler } } async function submit(wrapper: ReturnType['wrapper']) { await wrapper.get('.mint-artifact-save-dialog__submit').trigger('click') await flushPromises() } const TEST_FILE = new File(['content'], 'plot.png', { type: 'image/png' }) async function chooseFile(wrapper: ReturnType['wrapper'], file = TEST_FILE) { wrapper.findComponent(FileUploader).vm.$emit('upload', [file]) await flushPromises() } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('ArtifactSaveDialog', () => { beforeEach(() => { delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ window.history.replaceState({}, '', '/') }) it('should keep browser File required in the existing upload handler contract', () => { type UploadFilePayload = Extract expectTypeOf().toEqualTypeOf() }) // ------------------------------------------------------------------------- // JSON create // ------------------------------------------------------------------------- describe('json create', () => { it('should call the saveHandler once with a create payload', async () => { const { wrapper, saveHandler } = mountDialog({ mode: 'json', result: { x: 1 } }) await submit(wrapper) expect(saveHandler).toHaveBeenCalledOnce() expect(saveHandler).toHaveBeenCalledWith({ kind: 'json', mode: 'create', experimentId: 42, artifactKey: 'default', result: { x: 1 }, }) }) it('should use the typed artifact key and display name', async () => { const { wrapper, saveHandler } = mountDialog({ mode: 'json', result: { x: 1 } }) await wrapper.get('.mint-artifact-save-dialog__key input').setValue('my-fit') await wrapper.get('.mint-artifact-save-dialog__name input').setValue('My Fit') await submit(wrapper) const payload = saveHandler.mock.calls[0][0] expect(payload.artifactKey).toBe('my-fit') expect(payload).toMatchObject({ displayName: 'My Fit' }) }) it('should include a typed note as a string', async () => { const { wrapper, saveHandler } = mountDialog({ mode: 'json', result: { x: 1 } }) await wrapper.get('.mint-artifact-save-dialog__note textarea').setValue('first pass') await submit(wrapper) expect(saveHandler.mock.calls[0][0].note).toBe('first pass') }) it('should emit saved with the payload and close on success', async () => { const { wrapper, saveHandler } = mountDialog({ mode: 'json', result: { x: 1 } }) saveHandler.mockResolvedValueOnce({ cleanupPending: false }) await submit(wrapper) const saved = wrapper.emitted('saved') expect(saved).toHaveLength(1) expect((saved![0][0] as { payload: ArtifactSaveDialogPayload }).payload.kind).toBe('json') expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false]) }) it('should disable submit when no result payload is provided', async () => { const { wrapper, saveHandler } = mountDialog({ mode: 'json' }) await submit(wrapper) expect(saveHandler).not.toHaveBeenCalled() }) }) // ------------------------------------------------------------------------- // Key validation // ------------------------------------------------------------------------- describe('artifact key validation', () => { it('should block submit on an invalid key', async () => { const { wrapper, saveHandler } = mountDialog({ mode: 'json', result: { x: 1 } }) await wrapper.get('.mint-artifact-save-dialog__key input').setValue('Bad Key!') await submit(wrapper) expect(saveHandler).not.toHaveBeenCalled() }) it('should fall back to the default key when the input is emptied', async () => { const { wrapper, saveHandler } = mountDialog({ mode: 'json', result: { x: 1 }, defaultArtifactKey: 'fallback-key', }) await wrapper.get('.mint-artifact-save-dialog__key input').setValue('') await submit(wrapper) expect(saveHandler.mock.calls[0][0].artifactKey).toBe('fallback-key') }) }) // ------------------------------------------------------------------------- // File create // ------------------------------------------------------------------------- describe('file create', () => { it('should block submit until a file is chosen', async () => { const { wrapper, saveHandler } = mountDialog({ mode: 'file' }) await submit(wrapper) expect(saveHandler).not.toHaveBeenCalled() }) it('should send a file create payload with the chosen file', async () => { const { wrapper, saveHandler } = mountDialog({ mode: 'file' }) await chooseFile(wrapper) await submit(wrapper) expect(saveHandler).toHaveBeenCalledOnce() const payload = saveHandler.mock.calls[0][0] expect(payload).toEqual({ kind: 'file', mode: 'create', experimentId: 42, artifactKey: 'default', file: TEST_FILE, fileKind: 'file', filename: 'plot.png', contentType: 'image/png', }) }) it('should use the defaultFileKind prop', async () => { const { wrapper, saveHandler } = mountDialog({ mode: 'file', defaultFileKind: 'plot' }) await chooseFile(wrapper) await submit(wrapper) expect((saveHandler.mock.calls[0][0] as { fileKind: string }).fileKind).toBe('plot') }) it('should hide the uploader when file content is owned by the handler', () => { const { wrapper } = mountDialog({ mode: 'file', fileSource: 'handler' }) expect(wrapper.findComponent(FileUploader).exists()).toBe(false) }) it('should submit a handler-owned file create without a browser File', async () => { const { wrapper, saveHandler } = mountDialog({ mode: 'file', fileSource: 'handler' }) await submit(wrapper) expect(saveHandler.mock.calls[0][0]).toEqual({ kind: 'file', source: 'handler', mode: 'create', experimentId: 42, artifactKey: 'default', fileKind: 'file', }) }) }) // ------------------------------------------------------------------------- // Both mode // ------------------------------------------------------------------------- describe('both mode', () => { it('should default to the file variant and switch to json via the kind toggle', async () => { const { wrapper, saveHandler } = mountDialog({ mode: 'both', result: { x: 1 } }) expect(wrapper.findComponent(SegmentedControl).exists()).toBe(true) expect(wrapper.findComponent(FileUploader).exists()).toBe(true) wrapper.findComponent(SegmentedControl).vm.$emit('update:modelValue', 'json') await flushPromises() await submit(wrapper) expect(saveHandler.mock.calls[0][0].kind).toBe('json') }) }) // ------------------------------------------------------------------------- // Key collisions // ------------------------------------------------------------------------- describe('key collisions', () => { // The backend upserts a JSON artifact by key, but only sets display_name/note // when provided — the warning must say so rather than promise a clean replace. it('should warn and send upsert for a json save when the key exists', async () => { const { wrapper, saveHandler } = mountDialog({ mode: 'json', result: { x: 1 }, existingArtifactKeys: ['default'], }) expect(wrapper.get('.mint-artifact-save-dialog__warning').text()).toContain('kept unless') await submit(wrapper) expect(saveHandler.mock.calls[0][0].mode).toBe('upsert') }) // save_analysis_file_artifact is create-only: a colliding key always raises // ConflictException, and the dialog lacks the detail needed to replace safely. it('should block a file save when the key exists', async () => { const { wrapper, saveHandler } = mountDialog({ mode: 'file', existingArtifactKeys: ['default'], }) await chooseFile(wrapper) await submit(wrapper) expect(saveHandler).not.toHaveBeenCalled() expect(wrapper.get('.mint-artifact-save-dialog__key').text()).toContain('already used') }) it('should not warn when the key is unique', async () => { const { wrapper } = mountDialog({ mode: 'json', result: { x: 1 }, existingArtifactKeys: ['other'], }) expect(wrapper.find('.mint-artifact-save-dialog__warning').exists()).toBe(false) }) }) // ------------------------------------------------------------------------- // Update mode (existingArtifact) // ------------------------------------------------------------------------- describe('update mode', () => { it('should lock the artifact key to the existing artifact', async () => { const { wrapper } = mountDialog({ result: { x: 1 }, existingArtifact: makeDetail() }) const keyInput = wrapper.get('.mint-artifact-save-dialog__key input') expect((keyInput.element as HTMLInputElement).value).toBe('fit') expect(keyInput.attributes('readonly')).toBeDefined() }) it('should upsert a json artifact preserving its display name', async () => { const { wrapper, saveHandler } = mountDialog({ result: { x: 2 }, existingArtifact: makeDetail(), }) await submit(wrapper) expect(saveHandler.mock.calls[0][0]).toMatchObject({ kind: 'json', mode: 'upsert', artifactKey: 'fit', displayName: 'Dose Response Fit', result: { x: 2 }, }) }) // save_analysis_artifact ignores note=None (preserves), so it cannot clear a // note; offering the field here would silently discard the user's edit. it('should hide the note field when updating a json artifact', async () => { const { wrapper } = mountDialog({ result: { x: 2 }, existingArtifact: makeDetail() }) expect(wrapper.find('.mint-artifact-save-dialog__note').exists()).toBe(false) }) it('should never send a note on a json upsert', async () => { const { wrapper, saveHandler } = mountDialog({ result: { x: 2 }, existingArtifact: makeDetail({ note: 'baseline run' }), }) await submit(wrapper) expect('note' in saveHandler.mock.calls[0][0]).toBe(false) }) it('should keep the note field for file artifacts, which support tri-state notes', async () => { const { wrapper } = mountDialog({ existingArtifact: makeFileDetail() }) expect(wrapper.find('.mint-artifact-save-dialog__note').exists()).toBe(true) }) it('should send update-file payload with the immutable file kind', async () => { const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail() }) await chooseFile(wrapper) await submit(wrapper) expect(saveHandler.mock.calls[0][0]).toMatchObject({ kind: 'file', mode: 'update', artifactKey: 'report', fileKind: 'report', }) }) // update_analysis_file_artifact REPLACES result metadata rather than merging, // so omitting it would wipe whatever the artifact already carried. it('should carry the existing result metadata through a file replacement', async () => { const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail({ result: { schema_version: ANALYSIS_FILE_ARTIFACT_SCHEMA, kind: 'report', filename: 'qc-report.pdf', file: {}, metadata: { instrument: 'QE-HF', replicate: 2, passed: true }, }, }), }) await chooseFile(wrapper) await submit(wrapper) expect(saveHandler.mock.calls[0][0]).toMatchObject({ metadata: { instrument: 'QE-HF', replicate: 2, passed: true }, }) }) it('should omit metadata when the artifact has none', async () => { const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail() }) await chooseFile(wrapper) await submit(wrapper) expect('metadata' in saveHandler.mock.calls[0][0]).toBe(false) }) it('should let a metadata prop override the carried-through metadata', async () => { const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail({ result: { schema_version: ANALYSIS_FILE_ARTIFACT_SCHEMA, kind: 'report', filename: 'qc-report.pdf', file: {}, metadata: { stale: true }, }, }), metadata: { fresh: 'yes' }, }) await chooseFile(wrapper) await submit(wrapper) expect(saveHandler.mock.calls[0][0]).toMatchObject({ metadata: { fresh: 'yes' } }) }) it('should require a replacement file for file artifacts', async () => { const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail() }) await submit(wrapper) expect(saveHandler).not.toHaveBeenCalled() }) it('should omit note when untouched', async () => { const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail() }) await chooseFile(wrapper) await submit(wrapper) expect('note' in saveHandler.mock.calls[0][0]).toBe(false) }) it('should send null when a previously set note is cleared', async () => { const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail() }) await wrapper.get('.mint-artifact-save-dialog__note textarea').setValue('') await chooseFile(wrapper) await submit(wrapper) expect(saveHandler.mock.calls[0][0].note).toBeNull() }) it('should send the edited note as a string', async () => { const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail() }) await wrapper.get('.mint-artifact-save-dialog__note textarea').setValue('re-measured') await chooseFile(wrapper) await submit(wrapper) expect(saveHandler.mock.calls[0][0].note).toBe('re-measured') }) it('should submit a handler-owned file update without a browser File', async () => { const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail(), fileSource: 'handler', }) await wrapper.get('.mint-artifact-save-dialog__note textarea').setValue('') await submit(wrapper) expect(saveHandler.mock.calls[0][0]).toEqual({ kind: 'file', source: 'handler', mode: 'update', experimentId: 42, artifactKey: 'report', fileKind: 'report', note: null, }) }) }) // ------------------------------------------------------------------------- // Form lifecycle // ------------------------------------------------------------------------- describe('form lifecycle', () => { it('should keep in-progress edits when the same artifact is re-fetched', async () => { const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail() }) await wrapper.get('.mint-artifact-save-dialog__note textarea').setValue('mid-edit note') // A host poll hands back an equal-but-new detail object. await wrapper.setProps({ existingArtifact: makeFileDetail() }) expect((wrapper.get('.mint-artifact-save-dialog__note textarea').element as HTMLTextAreaElement).value) .toBe('mid-edit note') await chooseFile(wrapper) await submit(wrapper) expect(saveHandler.mock.calls[0][0].note).toBe('mid-edit note') }) it('should reset the form when a different artifact is supplied', async () => { const { wrapper } = mountDialog({ existingArtifact: makeFileDetail() }) await wrapper.get('.mint-artifact-save-dialog__note textarea').setValue('stale edit') await wrapper.setProps({ existingArtifact: makeFileDetail({ id: 9, artifact_key: 'other-report', note: 'fresh' }), }) expect((wrapper.get('.mint-artifact-save-dialog__key input').element as HTMLInputElement).value) .toBe('other-report') expect((wrapper.get('.mint-artifact-save-dialog__note textarea').element as HTMLTextAreaElement).value) .toBe('fresh') }) }) // ------------------------------------------------------------------------- // Error handling // ------------------------------------------------------------------------- describe('error handling', () => { it('should stay open, render the error, and emit error when the handler rejects', async () => { const { wrapper, saveHandler } = mountDialog({ mode: 'json', result: { x: 1 } }) saveHandler.mockRejectedValueOnce(new Error('Backend unavailable')) await submit(wrapper) expect(wrapper.emitted('update:modelValue')).toBeUndefined() expect(wrapper.emitted('error')).toEqual([['Backend unavailable']]) expect(wrapper.get('.mint-artifact-save-dialog__error').text()).toContain('Backend unavailable') expect(wrapper.emitted('saved')).toBeUndefined() }) it('should surface cleanupPending from the handler result in saved', async () => { const { wrapper, saveHandler } = mountDialog({ existingArtifact: makeFileDetail() }) saveHandler.mockResolvedValueOnce({ cleanupPending: true }) await chooseFile(wrapper) await submit(wrapper) const saved = wrapper.emitted('saved')![0][0] as { cleanupPending?: boolean } expect(saved.cleanupPending).toBe(true) }) it('should set an error when no experiment id can be resolved', async () => { const { wrapper, saveHandler } = mountDialog({ mode: 'json', result: { x: 1 }, experimentId: null, }) await submit(wrapper) expect(saveHandler).not.toHaveBeenCalled() expect(wrapper.find('.mint-artifact-save-dialog__error').exists()).toBe(true) }) }) })