import { describe, it, expect, vi, beforeEach } from 'vitest' import { ref, nextTick } from 'vue' // --------------------------------------------------------------------------- // Mock useApi — intercepts all HTTP calls // --------------------------------------------------------------------------- const mockGet = vi.fn() const mockPost = vi.fn() const mockPatch = vi.fn() const mockUseApi = vi.fn((_options?: unknown) => ({ get: mockGet, post: mockPost, patch: mockPatch, })) vi.mock('../../composables/useApi', () => ({ useApi: (options: unknown) => mockUseApi(options), })) import { useAnalysisArtifacts } from '../../composables/useAnalysisArtifacts' import type { AnalysisArtifactDetail, AnalysisArtifactSummary, } from '../../types/analysisArtifactTypes' // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- function makeArtifact( overrides: Partial = {}, ): AnalysisArtifactSummary { return { id: 1, experiment_id: 42, plugin_id: 'plugin-a', artifact_key: 'default', display_name: 'Dose Response Fit', status: 'active', result_keys: ['summary', 'tree'], created_at: '2026-07-01T10:00:00Z', updated_at: '2026-07-02T10:00:00Z', ...overrides, } } const ARTIFACTS = [ makeArtifact({ id: 1, plugin_id: 'plugin-a', artifact_key: 'fit', display_name: 'Dose Response Fit' }), makeArtifact({ id: 2, plugin_id: 'plugin-b', artifact_key: 'peaks', display_name: 'Peak Table' }), makeArtifact({ id: 3, plugin_id: 'plugin-a', artifact_key: 'old-fit', display_name: 'Old Fit', status: 'archived', archived_at: '2026-07-03T10:00:00Z', }), ] function setup(options?: Parameters[0]) { return useAnalysisArtifacts({ experimentId: 42, ...options }) } /** Let watchers fire and their triggered requests settle. */ async function flush(): Promise { await nextTick() await Promise.resolve() await Promise.resolve() } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('useAnalysisArtifacts', () => { beforeEach(() => { vi.clearAllMocks() mockGet.mockResolvedValue({ artifacts: ARTIFACTS }) mockPost.mockResolvedValue(makeArtifact()) mockPatch.mockResolvedValue(makeArtifact()) delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ window.history.replaceState({}, '', '/') }) // ------------------------------------------------------------------------- // list // ------------------------------------------------------------------------- describe('list', () => { it('should call GET /experiments/{id}/analysis-artifacts with include_archived=false by default', async () => { const { list } = setup() await list() expect(mockGet).toHaveBeenCalledOnce() expect(mockGet).toHaveBeenCalledWith('/experiments/42/analysis-artifacts?include_archived=false') }) it('should pass include_archived=true when includeArchived option is set', async () => { const { list } = setup({ includeArchived: true }) await list() expect(mockGet).toHaveBeenCalledWith('/experiments/42/analysis-artifacts?include_archived=true') }) it('should store and return the fetched artifacts', async () => { const { list, artifacts } = setup() const result = await list() expect(result).toHaveLength(3) expect(artifacts.value).toEqual(ARTIFACTS) }) it('should filter to the given pluginId client-side', async () => { const { list, artifacts } = setup({ pluginId: 'plugin-a' }) await list() expect(artifacts.value.map((a) => a.id)).toEqual([1, 3]) }) it('should keep all plugins when pluginId is not provided', async () => { const { list, artifacts } = setup() await list() expect(new Set(artifacts.value.map((a) => a.plugin_id))).toEqual( new Set(['plugin-a', 'plugin-b']), ) }) it('should set error and skip the request when no experiment id is available', async () => { const { list, error } = useAnalysisArtifacts() const result = await list() expect(result).toEqual([]) expect(mockGet).not.toHaveBeenCalled() expect(error.value).toContain('experiment') }) it('should reset artifacts and set error on failure', async () => { const { list, artifacts, error } = setup() await list() expect(artifacts.value).toHaveLength(3) mockGet.mockRejectedValueOnce(new Error('Network error')) const result = await list() expect(result).toEqual([]) expect(artifacts.value).toEqual([]) expect(error.value).toBe('Network error') }) it('should toggle isLoading during the call', async () => { let resolveGet!: (v: unknown) => void mockGet.mockImplementationOnce(() => new Promise((r) => { resolveGet = r })) const { list, isLoading } = setup() expect(isLoading.value).toBe(false) const promise = list() expect(isLoading.value).toBe(true) resolveGet({ artifacts: [] }) await promise expect(isLoading.value).toBe(false) }) it('should update lastLoadedAt on success', async () => { const { list, lastLoadedAt } = setup() expect(lastLoadedAt.value).toBeNull() await list() expect(lastLoadedAt.value).toBeInstanceOf(Date) }) }) describe('resolveTarget', () => { it('should resolve the exact stable identity when the numeric hint is stale', async () => { const detail = { ...makeArtifact({ id: 2, experiment_id: 42, plugin_id: 'plugin-b', artifact_key: 'peaks', }), result: { schema_version: 'mint.analysis_file.v1' }, tree: [], summary: null, } satisfies AnalysisArtifactDetail mockGet .mockResolvedValueOnce({ artifacts: ARTIFACTS }) .mockResolvedValueOnce(detail) const { resolveTarget } = setup() const resolved = await resolveTarget({ experimentId: 42, artifactPluginId: 'plugin-b', artifactKey: 'peaks', artifactId: 999, }) expect({ resolvedId: resolved?.id, detailPath: mockGet.mock.calls[1]?.[0], }).toEqual({ resolvedId: 2, detailPath: '/experiments/42/analysis-artifacts/2', }) }) it('should fail explicitly when the stable identity is not available', async () => { const { resolveTarget, error } = setup() const resolved = await resolveTarget({ experimentId: 42, artifactPluginId: 'LEAF', artifactKey: 'missing', }) expect({ resolved, error: error.value }).toEqual({ resolved: null, error: 'Analysis artifact LEAF/missing is not available.', }) }) it('should reject a target from another experiment before making a request', async () => { const { resolveTarget, error } = setup() const resolved = await resolveTarget({ experimentId: 66, artifactPluginId: 'LEAF', artifactKey: 'result', }) expect({ resolved, error: error.value, requests: mockGet.mock.calls, }).toEqual({ resolved: null, error: 'Analysis artifact target belongs to experiment 66, not 42.', requests: [], }) }) }) // ------------------------------------------------------------------------- // experiment id resolution // ------------------------------------------------------------------------- describe('experiment id resolution', () => { it('should accept a ref experiment id', async () => { const experimentId = ref(7) const { list } = useAnalysisArtifacts({ experimentId }) await list() expect(mockGet).toHaveBeenCalledWith('/experiments/7/analysis-artifacts?include_archived=false') }) it('should accept a getter experiment id', async () => { const { list } = useAnalysisArtifacts({ experimentId: () => 9 }) await list() expect(mockGet).toHaveBeenCalledWith('/experiments/9/analysis-artifacts?include_archived=false') }) it('should fall back to the injected platform context', async () => { ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = { currentExperimentId: 33, } const { list } = useAnalysisArtifacts() await list() expect(mockGet).toHaveBeenCalledWith('/experiments/33/analysis-artifacts?include_archived=false') }) it('should fall back to the URL experiment id', async () => { window.history.replaceState({}, '', '/?experimentId=55') const { list } = useAnalysisArtifacts() await list() expect(mockGet).toHaveBeenCalledWith('/experiments/55/analysis-artifacts?include_archived=false') }) }) // ------------------------------------------------------------------------- // visibleArtifacts filtering // ------------------------------------------------------------------------- describe('visibleArtifacts', () => { it('should show only active artifacts by default', async () => { const { list, visibleArtifacts } = setup({ includeArchived: true }) await list() expect(visibleArtifacts.value.map((a) => a.id)).toEqual([1, 2]) }) it('should show archived artifacts when statusFilter is "archived"', async () => { const { list, visibleArtifacts, statusFilter } = setup({ includeArchived: true }) await list() statusFilter.value = 'archived' expect(visibleArtifacts.value.map((a) => a.id)).toEqual([3]) }) it('should show everything when statusFilter is "all"', async () => { const { list, visibleArtifacts, statusFilter } = setup({ includeArchived: true }) await list() statusFilter.value = 'all' expect(visibleArtifacts.value).toHaveLength(3) }) it('should refetch with include_archived=true when statusFilter widens past "active"', async () => { const { list, statusFilter } = setup() await list() expect(mockGet).toHaveBeenLastCalledWith('/experiments/42/analysis-artifacts?include_archived=false') statusFilter.value = 'all' await nextTick() await Promise.resolve() expect(mockGet).toHaveBeenLastCalledWith('/experiments/42/analysis-artifacts?include_archived=true') }) it('should filter by search over display_name and artifact_key (case-insensitive)', async () => { const { list, visibleArtifacts, search } = setup() await list() search.value = 'PEAK' expect(visibleArtifacts.value.map((a) => a.id)).toEqual([2]) search.value = 'fit' expect(visibleArtifacts.value.map((a) => a.id)).toEqual([1]) }) it('should filter by pluginFilter', async () => { const { list, visibleArtifacts, pluginFilter } = setup() await list() pluginFilter.value = 'plugin-b' expect(visibleArtifacts.value.map((a) => a.id)).toEqual([2]) }) }) // ------------------------------------------------------------------------- // getDetail // ------------------------------------------------------------------------- describe('getDetail', () => { it('should GET the artifact detail and store it in selectedDetail', async () => { const detail = { ...makeArtifact(), result: { x: 1 }, tree: [], summary: null } mockGet.mockResolvedValueOnce(detail) const { getDetail, selectedDetail } = setup() const result = await getDetail(1) expect(mockGet).toHaveBeenCalledWith('/experiments/42/analysis-artifacts/1') expect(result).toEqual(detail) expect(selectedDetail.value).toEqual(detail) }) it('should return null and set error on failure', async () => { mockGet.mockRejectedValueOnce(new Error('Not found')) const { getDetail, selectedDetail, error } = setup() const result = await getDetail(99) expect(result).toBeNull() expect(selectedDetail.value).toBeNull() expect(error.value).toBe('Not found') }) }) // ------------------------------------------------------------------------- // mutations // ------------------------------------------------------------------------- describe('updateMetadata', () => { it('should PATCH the metadata and splice the returned summary into artifacts', async () => { const { list, updateMetadata, artifacts } = setup() await list() const renamed = makeArtifact({ id: 1, artifact_key: 'fit', display_name: 'Renamed' }) mockPatch.mockResolvedValueOnce(renamed) const updated = await updateMetadata(1, { display_name: 'Renamed' }) expect(updated).toMatchObject({ id: 1, display_name: 'Renamed' }) expect(mockPatch).toHaveBeenCalledWith('/experiments/42/analysis-artifacts/1', { display_name: 'Renamed', }) expect(artifacts.value.find((a) => a.id === 1)?.display_name).toBe('Renamed') }) it('should return null and set error on failure', async () => { mockPatch.mockRejectedValueOnce(new Error('Forbidden')) const { updateMetadata, error } = setup() const updated = await updateMetadata(1, { note: 'x' }) expect(updated).toBeNull() expect(error.value).toBe('Forbidden') }) }) describe('archive / restore', () => { it('should POST archive and splice the returned summary', async () => { const { list, archive, artifacts } = setup() await list() const archived = makeArtifact({ id: 1, artifact_key: 'fit', status: 'archived' }) mockPost.mockResolvedValueOnce(archived) const result = await archive(1) // Returns the post-mutation summary so callers never report a stale status. expect(result).toMatchObject({ id: 1, status: 'archived' }) expect(mockPost).toHaveBeenCalledWith('/experiments/42/analysis-artifacts/1/archive') expect(artifacts.value.find((a) => a.id === 1)?.status).toBe('archived') }) it('should POST restore and splice the returned summary', async () => { const { list, restore, artifacts } = setup({ includeArchived: true }) await list() const restored = makeArtifact({ id: 3, artifact_key: 'old-fit', status: 'active' }) mockPost.mockResolvedValueOnce(restored) const result = await restore(3) expect(result).toMatchObject({ id: 3, status: 'active' }) expect(mockPost).toHaveBeenCalledWith('/experiments/42/analysis-artifacts/3/restore') expect(artifacts.value.find((a) => a.id === 3)?.status).toBe('active') }) it('should keep isMutating true until every mutation settles', async () => { let resolveFirst!: (v: unknown) => void mockPost.mockImplementationOnce(() => new Promise((r) => { resolveFirst = r })) let resolveSecond!: (v: unknown) => void mockPost.mockImplementationOnce(() => new Promise((r) => { resolveSecond = r })) const { archive, isMutating } = setup() const first = archive(1) const second = archive(2) resolveSecond(makeArtifact({ id: 2, status: 'archived' })) await second expect(isMutating.value).toBe(true) resolveFirst(makeArtifact({ id: 1, status: 'archived' })) await first expect(isMutating.value).toBe(false) }) it('should toggle isMutating during archive', async () => { let resolvePost!: (v: unknown) => void mockPost.mockImplementationOnce(() => new Promise((r) => { resolvePost = r })) const { archive, isMutating } = setup() expect(isMutating.value).toBe(false) const promise = archive(1) expect(isMutating.value).toBe(true) resolvePost(makeArtifact()) await promise expect(isMutating.value).toBe(false) }) it('should return null on archive failure', async () => { mockPost.mockRejectedValueOnce(new Error('Conflict')) const { archive, error } = setup() const result = await archive(1) expect(result).toBeNull() expect(error.value).toBe('Conflict') }) }) // ------------------------------------------------------------------------- // helpers // ------------------------------------------------------------------------- describe('hasArtifactKey', () => { it('should report whether a key exists in the loaded artifacts', async () => { const { list, hasArtifactKey } = setup() await list() expect(hasArtifactKey('fit')).toBe(true) expect(hasArtifactKey('nope')).toBe(false) }) }) describe('clear', () => { it('should reset artifacts, selectedDetail, and error', async () => { mockGet.mockRejectedValueOnce(new Error('boom')) const { list, clear, artifacts, selectedDetail, error } = setup() await list() expect(error.value).toBe('boom') clear() expect(artifacts.value).toEqual([]) expect(selectedDetail.value).toBeNull() expect(error.value).toBeNull() }) }) describe('immediate', () => { it('should fetch on creation when immediate is set and an experiment id exists', async () => { setup({ immediate: true }) await Promise.resolve() await Promise.resolve() expect(mockGet).toHaveBeenCalledWith('/experiments/42/analysis-artifacts?include_archived=false') }) it('should not fetch on creation without immediate', async () => { setup() await Promise.resolve() expect(mockGet).not.toHaveBeenCalled() }) it('should fetch once a late-resolving experiment id becomes available', async () => { const experimentId = ref(undefined) useAnalysisArtifacts({ experimentId, immediate: true }) await flush() expect(mockGet).not.toHaveBeenCalled() experimentId.value = 77 await flush() expect(mockGet).toHaveBeenCalledWith('/experiments/77/analysis-artifacts?include_archived=false') }) }) // ------------------------------------------------------------------------- // Reactivity // ------------------------------------------------------------------------- describe('reactive options', () => { it('should refetch when the experiment id changes after a load', async () => { const experimentId = ref(42) const { list } = useAnalysisArtifacts({ experimentId }) await list() experimentId.value = 43 await flush() expect(mockGet).toHaveBeenLastCalledWith('/experiments/43/analysis-artifacts?include_archived=false') }) it('should re-scope artifacts when a reactive pluginId changes without refetching', async () => { const pluginId = ref('plugin-a') const { list, artifacts } = useAnalysisArtifacts({ experimentId: 42, pluginId }) await list() expect(artifacts.value.map((a) => a.id)).toEqual([1, 3]) pluginId.value = 'plugin-b' await flush() expect(artifacts.value.map((a) => a.id)).toEqual([2]) expect(mockGet).toHaveBeenCalledOnce() }) it('should refetch with include_archived=true when a reactive includeArchived flips on', async () => { const includeArchived = ref(false) const { list } = useAnalysisArtifacts({ experimentId: 42, includeArchived }) await list() includeArchived.value = true await flush() expect(mockGet).toHaveBeenLastCalledWith('/experiments/42/analysis-artifacts?include_archived=true') }) it('should not fetch when statusFilter widens before anything requested a list', async () => { const { statusFilter } = setup() statusFilter.value = 'archived' await flush() expect(mockGet).not.toHaveBeenCalled() }) it('should refetch when statusFilter widens while the first list is still in flight', async () => { let resolveGet!: (v: unknown) => void mockGet.mockImplementationOnce(() => new Promise((r) => { resolveGet = r })) const { list, statusFilter } = setup() const pending = list() statusFilter.value = 'archived' await flush() expect(mockGet).toHaveBeenLastCalledWith('/experiments/42/analysis-artifacts?include_archived=true') resolveGet({ artifacts: [] }) await pending }) it('should refetch when statusFilter widens after a failed load', async () => { mockGet.mockRejectedValueOnce(new Error('Network error')) const { list, statusFilter } = setup() await list() statusFilter.value = 'archived' await flush() expect(mockGet).toHaveBeenLastCalledWith('/experiments/42/analysis-artifacts?include_archived=true') }) it('should clear artifacts and selection when the experiment id goes away', async () => { const experimentId = ref(42) const { list, getDetail, artifacts, selectedDetail } = useAnalysisArtifacts({ experimentId }) await list() mockGet.mockResolvedValueOnce({ ...makeArtifact(), result: {}, tree: [], summary: null }) await getDetail(1) expect(artifacts.value).toHaveLength(3) expect(selectedDetail.value).not.toBeNull() experimentId.value = undefined await flush() expect(artifacts.value).toEqual([]) expect(selectedDetail.value).toBeNull() }) it('should not let a request from a dropped experiment repopulate state', async () => { const experimentId = ref(42) let resolveGet!: (v: unknown) => void mockGet.mockImplementationOnce(() => new Promise((r) => { resolveGet = r })) const { list, artifacts } = useAnalysisArtifacts({ experimentId }) const pending = list() experimentId.value = undefined await flush() resolveGet({ artifacts: ARTIFACTS }) await pending expect(artifacts.value).toEqual([]) }) it('should ignore a stale failure that lands after a newer success', async () => { let rejectStale!: (e: unknown) => void mockGet.mockImplementationOnce(() => new Promise((_r, reject) => { rejectStale = reject })) const { list, artifacts, error } = setup() const stalePromise = list() mockGet.mockResolvedValueOnce({ artifacts: ARTIFACTS }) await list() expect(artifacts.value).toHaveLength(3) rejectStale(new Error('Stale network error')) await stalePromise expect(error.value).toBeNull() expect(artifacts.value).toHaveLength(3) }) it('should ignore a stale detail response that resolves after a newer one', async () => { let resolveStale!: (v: unknown) => void mockGet.mockImplementationOnce(() => new Promise((r) => { resolveStale = r })) const { getDetail, selectedDetail } = setup() const stalePromise = getDetail(1) const fresh = { ...makeArtifact({ id: 2 }), result: { fresh: true }, tree: [], summary: null } mockGet.mockResolvedValueOnce(fresh) await getDetail(2) expect(selectedDetail.value?.id).toBe(2) resolveStale({ ...makeArtifact({ id: 1 }), result: { stale: true }, tree: [], summary: null }) const staleResult = await stalePromise expect(staleResult).toBeNull() expect(selectedDetail.value?.id).toBe(2) }) it('should keep isLoading true while any request is still in flight', async () => { let resolveFirst!: (v: unknown) => void mockGet.mockImplementationOnce(() => new Promise((r) => { resolveFirst = r })) let resolveSecond!: (v: unknown) => void mockGet.mockImplementationOnce(() => new Promise((r) => { resolveSecond = r })) const { list, isLoading } = setup() const first = list() const second = list() expect(isLoading.value).toBe(true) resolveSecond({ artifacts: [] }) await second resolveFirst({ artifacts: [] }) await first expect(isLoading.value).toBe(false) }) it('should ignore a stale in-flight response that resolves after a newer one', async () => { const stale = [makeArtifact({ id: 99, display_name: 'Stale' })] let resolveStale!: (v: unknown) => void mockGet.mockImplementationOnce(() => new Promise((r) => { resolveStale = r })) const { list, artifacts } = setup() const stalePromise = list() mockGet.mockResolvedValueOnce({ artifacts: ARTIFACTS }) await list() expect(artifacts.value).toHaveLength(3) resolveStale({ artifacts: stale }) await stalePromise expect(artifacts.value.map((a) => a.id)).toEqual([1, 2, 3]) }) }) })