import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import { nextTick } from 'vue' import axios, { type AxiosRequestConfig } from 'axios' import { useExperimentSelector } from '../../composables/useExperimentSelector' import { useApi } from '../../composables/useApi' import type { ExperimentListResponse, ExperimentSummary } from '../../types' let fakeResponse: ExperimentListResponse let fakeError: Error | null = null let recordedUrls: string[] = [] function experiment(id: number, overrides: Partial = {}): ExperimentSummary { return { id, name: `Experiment ${id}`, status: 'planned', experiment_type: 'screen', created_at: '2026-05-07T08:00:00Z', updated_at: '2026-05-07T08:00:00Z', has_design_data: false, ...overrides, } } function deferredResponse() { let resolve!: (value: { data: ExperimentListResponse }) => void const promise = new Promise<{ data: ExperimentListResponse }>((resolvePromise) => { resolve = resolvePromise }) return { promise, resolve } } describe('useExperimentSelector', () => { beforeEach(() => { setActivePinia(createPinia()) fakeResponse = { experiments: [experiment(1)], total: 1, } fakeError = null recordedUrls = [] delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ vi.spyOn(axios.Axios.prototype, 'get').mockImplementation(async function ( this: unknown, url: string, _config?: AxiosRequestConfig, ) { recordedUrls.push(url) if (fakeError) throw fakeError return { data: fakeResponse } }) }) afterEach(() => { vi.useRealTimers() vi.restoreAllMocks() }) it('fetches experiments, tracks loading, and marks lastLoadedAt', async () => { let resolveGet!: (value: { data: ExperimentListResponse }) => void vi.mocked(axios.Axios.prototype.get).mockImplementationOnce(async function ( this: unknown, url: string, _config?: AxiosRequestConfig, ) { recordedUrls.push(url) return new Promise((resolve) => { resolveGet = resolve }) }) const hook = useExperimentSelector({ limit: 25 }) expect(hook.isLoading.value).toBe(false) expect(hook.lastLoadedAt.value).toBeNull() const promise = hook.fetch() expect(hook.isLoading.value).toBe(true) resolveGet({ data: fakeResponse }) await promise expect(hook.isLoading.value).toBe(false) expect(hook.error.value).toBeNull() expect(hook.experiments.value).toHaveLength(1) expect(hook.total.value).toBe(1) expect(hook.lastLoadedAt.value).toBeInstanceOf(Date) expect(recordedUrls[0]).toContain('/experiments?') expect(recordedUrls[0]).toContain('limit=25') expect(recordedUrls[0]).toContain('skip=0') expect(recordedUrls[0]).toContain('sort_by=created_at') }) it('uses a single allowed experiment type as the platform query filter', async () => { const hook = useExperimentSelector({ allowedExperimentTypes: ['drug-response'], }) await hook.fetch() expect(recordedUrls[0]).toContain('experiment_type=drug-response') }) it('returns an empty page without querying when the selected type is not allowed', async () => { const hook = useExperimentSelector({ experimentType: 'proteomics', allowedExperimentTypes: ['drug-response'], }) await hook.fetch() expect(recordedUrls).toEqual([]) expect(hook.experiments.value).toEqual([]) expect(hook.total.value).toBe(0) }) it('queries each allowed experiment type and merges multi-type pages', async () => { vi.spyOn(useApi().client, 'get').mockImplementation(async function ( this: unknown, url: string, _config?: AxiosRequestConfig, ) { recordedUrls.push(url) const type = new URL(url, 'http://mint.test').searchParams.get('experiment_type') if (type === 'drug-response') { return { data: { experiments: [experiment(1, { experiment_type: 'drug-response', created_at: '2026-05-08T08:00:00Z', })], total: 1, }, } } if (type === 'metabolomics') { return { data: { experiments: [experiment(2, { experiment_type: 'metabolomics', created_at: '2026-05-09T08:00:00Z', })], total: 1, }, } } return { data: { experiments: [], total: 0 } } }) const hook = useExperimentSelector({ allowedExperimentTypes: ['drug-response', 'metabolomics'], limit: 10, }) await hook.fetch() expect(recordedUrls).toHaveLength(2) expect(recordedUrls[0]).toContain('experiment_type=drug-response') expect(recordedUrls[1]).toContain('experiment_type=metabolomics') expect(hook.experiments.value.map(item => item.id)).toEqual([2, 1]) expect(hook.total.value).toBe(2) }) it('filters experiment type options to the allowed platform scope', async () => { vi.spyOn(useApi().client, 'get').mockImplementation(async function ( this: unknown, url: string, _config?: AxiosRequestConfig, ) { recordedUrls.push(url) if (url.includes('experiment-types')) { return { data: [ { value: 'drug-response', label: 'Drug Response', color: '#0EA5E9' }, { value: 'proteomics', label: 'Proteomics', color: '#22C55E' }, ], } } if (url.includes('projects')) { return { data: { projects: [], total: 0 } } } return { data: fakeResponse } }) const hook = useExperimentSelector({ allowedExperimentTypes: ['drug-response'], }) await hook.fetchFilterOptions() expect(hook.experimentTypes.value).toEqual([ { value: 'drug-response', label: 'Drug Response', color: '#0EA5E9' }, ]) }) it('loadMore appends the next page', async () => { const hook = useExperimentSelector({ limit: 1 }) fakeResponse = { experiments: [experiment(1)], total: 2 } await hook.fetch() fakeResponse = { experiments: [experiment(2)], total: 2 } await hook.loadMore() expect(hook.experiments.value.map(item => item.id)).toEqual([1, 2]) expect(hook.page.value).toBe(1) expect(recordedUrls[1]).toContain('skip=1') }) it('sets error and clears first page results on failure', async () => { fakeError = new Error('network down') const hook = useExperimentSelector() await hook.fetch() expect(hook.error.value).toBe('network down') expect(hook.experiments.value).toEqual([]) expect(hook.total.value).toBe(0) expect(hook.lastLoadedAt.value).toBeNull() }) it('clear resets selection, filters, sort, and errors', async () => { const hook = useExperimentSelector() hook.select(experiment(7)) hook.filters.search = 'drug' hook.sortKey.value = 'name:asc' fakeError = new Error('failed once') await hook.fetch() expect(hook.error.value).toBe('failed once') hook.clear() expect(hook.selectedExperiment.value).toBeNull() expect(hook.filters.search).toBeUndefined() expect(hook.sortKey.value).toBe('created_at:desc') expect(hook.error.value).toBeNull() }) it('debounces search filter fetches', async () => { vi.useFakeTimers() const hook = useExperimentSelector() await hook.fetch() hook.filters.search = 'glu' await nextTick() hook.filters.search = 'glucose' await nextTick() await vi.advanceTimersByTimeAsync(299) expect(recordedUrls).toHaveLength(1) await vi.advanceTimersByTimeAsync(1) expect(recordedUrls).toHaveLength(2) expect(recordedUrls[1]).toContain('search=glucose') }) it('cancels pending search debounce when discrete filters change', async () => { vi.useFakeTimers() const hook = useExperimentSelector() await hook.fetch() hook.filters.search = 'pending' await nextTick() hook.filters.status = 'planned' await nextTick() await Promise.resolve() expect(recordedUrls).toHaveLength(2) expect(recordedUrls[1]).toContain('status=planned') await vi.advanceTimersByTimeAsync(300) expect(recordedUrls).toHaveLength(2) }) it('does not let an older list response overwrite the latest filters', async () => { vi.useFakeTimers() const older = deferredResponse() const newer = deferredResponse() vi.spyOn(useApi().client, 'get') .mockImplementationOnce(() => older.promise) .mockImplementationOnce(() => newer.promise) const hook = useExperimentSelector() hook.filters.search = 'old' const olderFetch = hook.fetch() hook.filters.search = 'new' const newerFetch = hook.fetch() newer.resolve({ data: { experiments: [experiment(2, { name: 'New' })], total: 1 }, }) await newerFetch older.resolve({ data: { experiments: [experiment(1, { name: 'Old' })], total: 1 }, }) await olderFetch expect(hook.experiments.value.map(item => item.name)).toEqual(['New']) }) it('invalidates an active request before a debounced search starts', async () => { vi.useFakeTimers() const older = deferredResponse() vi.spyOn(useApi().client, 'get').mockImplementationOnce(() => older.promise) const hook = useExperimentSelector() const olderFetch = hook.fetch() hook.filters.search = 'new' await nextTick() older.resolve({ data: { experiments: [experiment(1, { name: 'Old' })], total: 1 }, }) await olderFetch expect(hook.experiments.value).toEqual([]) }) it('keeps loading true when a stale request finishes before the latest request', async () => { vi.useFakeTimers() const older = deferredResponse() const newer = deferredResponse() vi.spyOn(useApi().client, 'get') .mockImplementationOnce(() => older.promise) .mockImplementationOnce(() => newer.promise) const hook = useExperimentSelector() const olderFetch = hook.fetch() const newerFetch = hook.fetch() older.resolve({ data: { experiments: [experiment(1)], total: 1 } }) await olderFetch expect(hook.isLoading.value).toBe(true) newer.resolve({ data: { experiments: [experiment(2)], total: 1 } }) await newerFetch }) })