import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import axios, { type AxiosRequestConfig } from 'axios' import { useExperimentStore } from '../../stores/experiment' import type { ExperimentSummary } from '../../types' let fakeResponse: ExperimentSummary let fakeError: (Error & { response?: { status: number } }) | null = null let recordedUrls: string[] = [] // Lets a test hold individual requests open so responses can be landed out of // the order they were issued. type Deferred = { settle: (experiment: ExperimentSummary) => void } let deferreds: Map let deferResponses = false function idFromUrl(url: string): number { return Number(/\/experiments\/(\d+)/.exec(url)?.[1]) } function landResponse(id: number, experiment: ExperimentSummary): void { const deferred = deferreds.get(id) if (!deferred) throw new Error(`No request in flight for experiment ${id}`) deferred.settle(experiment) } function experiment(id: number, overrides: Partial = {}): ExperimentSummary { return { id, name: 'Hexokinase knockout run', experiment_code: `EXP-${String(id).padStart(3, '0')}`, status: 'ongoing', experiment_type: 'screen', created_at: '2026-07-01T08:00:00Z', updated_at: '2026-07-01T08:00:00Z', has_design_data: false, ...overrides, } } function installLocalStorage() { const storage: Record = {} vi.stubGlobal('localStorage', { getItem: vi.fn((key: string) => storage[key] ?? null), setItem: vi.fn((key: string, value: string) => { storage[key] = value }), removeItem: vi.fn((key: string) => { delete storage[key] }), clear: vi.fn(() => { for (const key of Object.keys(storage)) delete storage[key] }), } as unknown as Storage) } describe('useExperimentStore', () => { beforeEach(() => { setActivePinia(createPinia()) installLocalStorage() fakeResponse = experiment(42) fakeError = null recordedUrls = [] deferreds = new Map() deferResponses = false vi.spyOn(axios.Axios.prototype, 'get').mockImplementation(async function ( this: unknown, url: string, _config?: AxiosRequestConfig, ) { recordedUrls.push(url) if (fakeError) throw fakeError if (deferResponses) { return await new Promise(resolve => { deferreds.set(idFromUrl(url), { settle: experiment => resolve({ data: experiment }), }) }) } return { data: fakeResponse } }) }) afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() }) describe('selectById', () => { it('should fetch the record from the platform when given only an id', async () => { const store = useExperimentStore() await store.selectById(42) expect(recordedUrls.some(url => url.includes('/experiments/42'))).toBe(true) }) it('should expose the server-provided name when resolution succeeds', async () => { const store = useExperimentStore() await store.selectById(42) expect(store.current?.name).toBe('Hexokinase knockout run') }) it('should expose the server-provided experiment code when resolution succeeds', async () => { const store = useExperimentStore() await store.selectById(42) expect(store.current?.experiment_code).toBe('EXP-042') }) it('should leave the current experiment null when resolution fails', async () => { fakeError = Object.assign(new Error('Not found'), { response: { status: 404 } }) const store = useExperimentStore() await store.selectById(42) expect(store.current).toBeNull() }) it('should never synthesise a name from the id when resolution fails', async () => { fakeError = Object.assign(new Error('Forbidden'), { response: { status: 403 } }) const store = useExperimentStore() await store.selectById(42) expect(store.current?.name).not.toBe('Experiment 42') }) it('should record an error message when resolution fails', async () => { fakeError = Object.assign(new Error('Not found'), { response: { status: 404 } }) const store = useExperimentStore() await store.selectById(42) expect(store.error).not.toBeNull() }) it('should report the bound id even while the record is still resolving', async () => { const store = useExperimentStore() const pending = store.selectById(42) expect(store.currentId).toBe(42) await pending }) it('should clear the previous record while a different id is resolving', async () => { const store = useExperimentStore() store.select(experiment(7)) const pending = store.selectById(42) expect(store.current).toBeNull() await pending }) it('should issue a single request when the same id is resolved concurrently', async () => { const store = useExperimentStore() await Promise.all([store.selectById(42), store.selectById(42), store.selectById(42)]) expect(recordedUrls.filter(url => url.includes('/experiments/42'))).toHaveLength(1) }) it('should return the resolved record to every caller resolving the same id', async () => { const store = useExperimentStore() const results = await Promise.all([ store.selectById(42), store.selectById(42), store.selectById(42), ]) expect(results.map(result => result?.id)).toEqual([42, 42, 42]) }) it('should retry after a failure rather than caching the rejection', async () => { const store = useExperimentStore() fakeError = Object.assign(new Error('Boom'), { response: { status: 503 } }) await store.selectById(42) fakeError = null await store.selectById(42) expect(store.current?.name).toBe('Hexokinase knockout run') }) }) // A late response overwriting a newer selection shows the user a name that // belongs to a different experiment — the same visible failure this store // exists to prevent, reached by a different route. describe('out-of-order resolution', () => { beforeEach(() => { deferResponses = true }) it('should keep the modal selection when an earlier deep-link resolve lands late', async () => { const store = useExperimentStore() const pending = store.selectById(1) store.select(experiment(2, { name: 'Chosen from the modal' })) landResponse(1, experiment(1, { name: 'Stale deep-link record' })) await pending expect(store.current?.name).toBe('Chosen from the modal') }) it('should keep the modal id when an earlier resolve lands late', async () => { const store = useExperimentStore() const pending = store.selectById(1) store.select(experiment(2, { name: 'Chosen from the modal' })) landResponse(1, experiment(1, { name: 'Stale deep-link record' })) await pending expect(store.currentId).toBe(2) }) it('should show the newest experiment when two resolves settle in reverse order', async () => { const store = useExperimentStore() const first = store.selectById(1) const second = store.selectById(2) landResponse(2, experiment(2, { name: 'Second' })) await second landResponse(1, experiment(1, { name: 'First' })) await first expect(store.current?.name).toBe('Second') }) it('should still be resolving when a superseded request settles first', async () => { const store = useExperimentStore() const first = store.selectById(1) store.selectById(2) landResponse(1, experiment(1, { name: 'First' })) await first expect(store.isResolving).toBe(true) }) }) describe('select', () => { it('should adopt a record the platform already returned without refetching', () => { const store = useExperimentStore() store.select(experiment(7, { name: 'Glutamine tracing' })) expect(recordedUrls).toHaveLength(0) }) it('should expose the adopted record as the current experiment', () => { const store = useExperimentStore() store.select(experiment(7, { name: 'Glutamine tracing' })) expect(store.current?.name).toBe('Glutamine tracing') }) }) describe('shared state', () => { it('should share the current experiment across separate store instances', () => { useExperimentStore().select(experiment(7, { name: 'Glutamine tracing' })) expect(useExperimentStore().current?.name).toBe('Glutamine tracing') }) }) describe('browser storage isolation', () => { it('should not persist a modal selection', () => { const store = useExperimentStore() store.select(experiment(7, { name: 'Glutamine tracing' })) expect(localStorage.setItem).not.toHaveBeenCalled() }) it('should not persist an explicitly resolved deep link', async () => { const store = useExperimentStore() await store.selectById(42) expect(localStorage.setItem).not.toHaveBeenCalled() }) it('should not touch browser storage when detached', () => { const store = useExperimentStore() store.select(experiment(7)) store.clear() expect(localStorage.removeItem).not.toHaveBeenCalled() }) }) })