import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { ref, computed, nextTick } from 'vue' import { createPinia, setActivePinia } from 'pinia' import axios from 'axios' // Capture side-effect callbacks so tests can invoke them directly let capturedScopeDispose: (() => void) | null = null let capturedProvideKey: symbol | null = null let capturedProvideValue: unknown = null vi.mock('vue', async () => { const actual = await vi.importActual('vue') return { ...actual, provide: vi.fn((key: symbol, value: unknown) => { capturedProvideKey = key capturedProvideValue = value }), onScopeDispose: vi.fn((cb: () => void) => { capturedScopeDispose = cb }), } }) import { useAppExperiment, APP_EXPERIMENT_KEY } from '../../composables/useAppExperiment' import type { AppExperimentState } from '../../composables/useAppExperiment' import { useExperimentStore } from '../../stores/experiment' import type { ExperimentSummary } from '../../types' // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeExperiment(overrides: Partial = {}): ExperimentSummary { return { id: 42, name: 'Test Experiment', status: 'planned', experiment_type: 'default', created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-01T00:00:00Z', has_design_data: false, ...overrides, } } function getState(): AppExperimentState { return capturedProvideValue as AppExperimentState } interface PendingFetch { promise: Promise<{ data: ExperimentSummary }> resolveWith: (experiment: ExperimentSummary) => void rejectWith: (error: Error) => void } // Read at call time, not at spy-install time: axios binds its prototype methods // into each instance when the instance is created, so a spy installed per test // never reaches the client the SDK already built. let pendingFetch: PendingFetch | null = null /** * Holds the platform request open so tests can observe the composable while the * store is mid-resolve — the state an id-only deep link starts in. */ function stubExperimentFetch(): PendingFetch { let settle!: (result: { data: ExperimentSummary } | Error) => void const promise = new Promise<{ data: ExperimentSummary }>((resolve, reject) => { settle = (result) => { if (result instanceof Error) reject(result) else resolve(result) } }) pendingFetch = { promise, resolveWith: (experiment: ExperimentSummary) => settle({ data: experiment }), rejectWith: (error: Error) => settle(error), } return pendingFetch } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('useAppExperiment', () => { beforeEach(() => { capturedScopeDispose = null capturedProvideKey = null capturedProvideValue = null setActivePinia(createPinia()) pendingFetch = null vi.spyOn(axios.Axios.prototype, 'get').mockImplementation((() => { if (!pendingFetch) return Promise.reject(new Error('Unexpected platform request')) return pendingFetch.promise }) as never) vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() vi.restoreAllMocks() }) // ------------------------------------------------------------------------- // provide() // ------------------------------------------------------------------------- describe('provide', () => { it('should call provide with APP_EXPERIMENT_KEY', () => { useAppExperiment() expect(capturedProvideKey).toBe(APP_EXPERIMENT_KEY) }) it('should provide a non-null state object', () => { useAppExperiment() expect(capturedProvideValue).toBeTruthy() }) }) // ------------------------------------------------------------------------- // Store-backed state // ------------------------------------------------------------------------- describe('store-backed experiment state', () => { it('should mirror a record the platform already returned', () => { const { experimentName, experimentCode, experimentId } = useAppExperiment() useExperimentStore().select( makeExperiment({ id: 8, name: 'Initial run', experiment_code: 'EXP-008' }), ) expect(experimentName.value).toBe('Initial run') expect(experimentCode.value).toBe('EXP-008') expect(experimentId.value).toBe(8) }) it('should leave experimentCode undefined for a record without a code', () => { const { experimentCode } = useAppExperiment() useExperimentStore().select(makeExperiment({ id: 7, name: 'Alpha Run' })) expect(experimentCode.value).toBeUndefined() }) it('should follow the store when the selection changes', () => { const store = useExperimentStore() const { experimentName, experimentCode, experimentId } = useAppExperiment() store.select(makeExperiment({ id: 8, name: 'Initial run', experiment_code: 'EXP-008' })) store.select(makeExperiment({ id: 9, name: 'Updated run', experiment_code: 'EXP-009' })) expect(experimentName.value).toBe('Updated run') expect(experimentCode.value).toBe('EXP-009') expect(experimentId.value).toBe(9) }) it('should expose the status from the store record', () => { useAppExperiment() useExperimentStore().select(makeExperiment({ status: 'completed' })) expect(getState().experimentStatus.value).toBe('completed') }) it('should reset when the store is cleared', () => { const store = useExperimentStore() const { experimentName, experimentCode, experimentStatus, experimentId } = useAppExperiment() store.select(makeExperiment({ id: 99, name: 'To Be Cleared', experiment_code: 'EXP-099' })) store.clear() expect(experimentName.value).toBeUndefined() expect(experimentCode.value).toBeUndefined() expect(experimentStatus.value).toBeUndefined() expect(experimentId.value).toBeNull() }) it('should report isResolving while the store resolves an id', async () => { const fetch = stubExperimentFetch() const store = useExperimentStore() const { isResolving } = useAppExperiment() const pending = store.selectById(8) expect(isResolving.value).toBe(true) fetch.resolveWith(makeExperiment({ id: 8, name: 'Resolved run' })) await pending expect(isResolving.value).toBe(false) }) it('should expose the bound id but no name while an id-only binding resolves', async () => { const fetch = stubExperimentFetch() const store = useExperimentStore() const { experimentId, experimentName, experimentCode } = useAppExperiment() const pending = store.selectById(8) expect(experimentId.value).toBe(8) expect(experimentName.value).toBeUndefined() expect(experimentCode.value).toBeUndefined() fetch.resolveWith(makeExperiment({ id: 8, name: 'Resolved run' })) await pending }) it('should take the name from the platform once an id-only binding resolves', async () => { const fetch = stubExperimentFetch() const store = useExperimentStore() const { experimentName } = useAppExperiment() const pending = store.selectById(8) fetch.resolveWith(makeExperiment({ id: 8, name: 'Hexokinase knockout run' })) await pending expect(experimentName.value).toBe('Hexokinase knockout run') }) it('should surface the store error when resolution fails', async () => { const fetch = stubExperimentFetch() const store = useExperimentStore() const { error } = useAppExperiment() const pending = store.selectById(8) fetch.rejectWith(new Error('Experiment not found')) await pending expect(error.value).toBe('Experiment not found') }) it('should expose no name after a failed resolve, keeping the bound id', async () => { const fetch = stubExperimentFetch() const store = useExperimentStore() const { experimentName, experimentCode, experimentId } = useAppExperiment() const pending = store.selectById(8) fetch.rejectWith(new Error('Experiment not found')) await pending expect(experimentName.value).toBeUndefined() expect(experimentCode.value).toBeUndefined() expect(experimentId.value).toBe(8) }) }) describe('returned refs are read-only projections of the store', () => { it('should expose experimentName as undefined with nothing selected', () => { const { experimentName } = useAppExperiment() expect(experimentName.value).toBeUndefined() }) it('should expose experimentId as null with nothing selected', () => { const { experimentId } = useAppExperiment() expect(experimentId.value).toBeNull() }) }) // ------------------------------------------------------------------------- // openModal / closeModal // ------------------------------------------------------------------------- describe('openModal and closeModal', () => { it('should set showModal to true when openModal is called', () => { useAppExperiment() const state = getState() expect(state.showModal.value).toBe(false) state.openModal() expect(state.showModal.value).toBe(true) }) it('should set showModal to false when closeModal is called', () => { useAppExperiment() const state = getState() state.openModal() state.closeModal() expect(state.showModal.value).toBe(false) }) }) // ------------------------------------------------------------------------- // onSelect — fired by the store adopting an experiment, not by the shell // ------------------------------------------------------------------------- describe('onSelect', () => { it('should call onSelect when the store adopts an experiment', async () => { const onSelect = vi.fn() useAppExperiment({ onSelect }) useExperimentStore().select(makeExperiment()) await nextTick() expect(onSelect).toHaveBeenCalledOnce() }) it('should pass the platform record through unchanged', async () => { const onSelect = vi.fn() useAppExperiment({ onSelect }) const experiment = makeExperiment({ id: 5, name: 'Selected Exp' }) useExperimentStore().select(experiment) await nextTick() expect(onSelect).toHaveBeenCalledWith(experiment) }) it('should call onSelect when an id-only binding resolves', async () => { const fetch = stubExperimentFetch() const onSelect = vi.fn() const store = useExperimentStore() useAppExperiment({ onSelect }) const pending = store.selectById(8) fetch.resolveWith(makeExperiment({ id: 8, name: 'Resolved run' })) await pending await nextTick() expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ name: 'Resolved run' })) }) it('should not call onSelect when the store is cleared', async () => { const onSelect = vi.fn() const store = useExperimentStore() useAppExperiment({ onSelect }) store.select(makeExperiment()) await nextTick() onSelect.mockClear() store.clear() await nextTick() expect(onSelect).not.toHaveBeenCalled() }) it('should not call onSelect for an experiment already selected at setup', async () => { const onSelect = vi.fn() useExperimentStore().select(makeExperiment()) useAppExperiment({ onSelect }) await nextTick() expect(onSelect).not.toHaveBeenCalled() }) it('should not throw when no onSelect callback is provided', async () => { useAppExperiment() expect(() => useExperimentStore().select(makeExperiment())).not.toThrow() await nextTick() }) }) // ------------------------------------------------------------------------- // handleSave // ------------------------------------------------------------------------- describe('handleSave', () => { it('should call onSave when invoked', async () => { const onSave = vi.fn().mockResolvedValue(null) useAppExperiment({ onSave }) const state = getState() await state.handleSave() expect(onSave).toHaveBeenCalledOnce() }) it('should set saveSuccessMessage when onSave returns a string', async () => { const onSave = vi.fn().mockResolvedValue('Saved successfully!') useAppExperiment({ onSave }) const state = getState() await state.handleSave() expect(state.saveSuccessMessage.value).toBe('Saved successfully!') }) it('should auto-clear saveSuccessMessage after 3 seconds', async () => { const onSave = vi.fn().mockResolvedValue('Done') useAppExperiment({ onSave }) const state = getState() await state.handleSave() expect(state.saveSuccessMessage.value).toBe('Done') vi.advanceTimersByTime(3000) expect(state.saveSuccessMessage.value).toBeUndefined() }) it('should not clear saveSuccessMessage before 3 seconds have elapsed', async () => { const onSave = vi.fn().mockResolvedValue('Pending') useAppExperiment({ onSave }) const state = getState() await state.handleSave() vi.advanceTimersByTime(2999) expect(state.saveSuccessMessage.value).toBe('Pending') }) it('should reset the 3-second timer on a subsequent successful save', async () => { const onSave = vi.fn().mockResolvedValue('Resaved') useAppExperiment({ onSave }) const state = getState() await state.handleSave() vi.advanceTimersByTime(2000) await state.handleSave() vi.advanceTimersByTime(2000) // Only 2s have passed since the second save — message should still be set expect(state.saveSuccessMessage.value).toBe('Resaved') vi.advanceTimersByTime(1000) expect(state.saveSuccessMessage.value).toBeUndefined() }) it('should manage saveLoading: true during save, false after', async () => { let resolveSave!: (v: string | null) => void const onSave = vi.fn( () => new Promise((resolve) => { resolveSave = resolve }) ) useAppExperiment({ onSave }) const state = getState() const savePromise = state.handleSave() expect(state.saveLoading.value).toBe(true) resolveSave(null) await savePromise expect(state.saveLoading.value).toBe(false) }) it('should set saveLoading to false even when onSave rejects', async () => { const onSave = vi.fn().mockRejectedValue(new Error('network error')) useAppExperiment({ onSave }) const state = getState() await state.handleSave().catch(() => {}) expect(state.saveLoading.value).toBe(false) }) it('should be a no-op (not call onSave) when saveLoading is already true', async () => { let resolveSave!: (v: string | null) => void const onSave = vi.fn( () => new Promise((resolve) => { resolveSave = resolve }) ) useAppExperiment({ onSave }) const state = getState() // Start first save but do not await it yet const first = state.handleSave() // Attempt second save while first is in-flight await state.handleSave() resolveSave(null) await first expect(onSave).toHaveBeenCalledTimes(1) }) it('should not call onSave and return immediately when no onSave option is provided', async () => { useAppExperiment() const state = getState() // Should not throw and saveLoading should remain false await state.handleSave() expect(state.saveLoading.value).toBe(false) }) it('should not set saveSuccessMessage when onSave returns null', async () => { const onSave = vi.fn().mockResolvedValue(null) useAppExperiment({ onSave }) const state = getState() await state.handleSave() expect(state.saveSuccessMessage.value).toBeUndefined() }) }) // ------------------------------------------------------------------------- // handleDetach // ------------------------------------------------------------------------- describe('handleDetach', () => { it('should clear the shared store', () => { const store = useExperimentStore() const { experimentId, experimentName } = useAppExperiment() const state = getState() store.select(makeExperiment({ id: 3, name: 'Detachable' })) state.handleDetach() expect(store.current).toBeNull() expect(store.currentId).toBeNull() expect(experimentId.value).toBeNull() expect(experimentName.value).toBeUndefined() }) it('should clear a binding whose resolution failed', async () => { const fetch = stubExperimentFetch() const store = useExperimentStore() const { experimentId, error } = useAppExperiment() const state = getState() const pending = store.selectById(8) fetch.rejectWith(new Error('Experiment not found')) await pending state.handleDetach() expect(experimentId.value).toBeNull() expect(error.value).toBeNull() }) it('should call onDetach callback', () => { const onDetach = vi.fn() useAppExperiment({ onDetach }) const state = getState() state.handleDetach() expect(onDetach).toHaveBeenCalledOnce() }) it('should not throw when no onDetach callback is provided', () => { useAppExperiment() const state = getState() expect(() => state.handleDetach()).not.toThrow() }) it('should call onDetach after clearing state', () => { const calls: string[] = [] const { experimentId } = useAppExperiment({ onDetach: () => { // By the time onDetach fires, experimentId should already be null calls.push(experimentId.value === null ? 'cleared' : 'not-cleared') }, }) const state = getState() useExperimentStore().select(makeExperiment()) state.handleDetach() expect(calls).toEqual(['cleared']) }) }) // ------------------------------------------------------------------------- // showSave // ------------------------------------------------------------------------- describe('showSave', () => { it('should be true when onSave is provided', () => { useAppExperiment({ onSave: vi.fn().mockResolvedValue(null) }) expect(getState().showSave.value).toBe(true) }) it('should be false when onSave is not provided', () => { useAppExperiment() expect(getState().showSave.value).toBe(false) }) }) // ------------------------------------------------------------------------- // showDetach // ------------------------------------------------------------------------- describe('showDetach', () => { it('should be false initially (no experiment set)', () => { useAppExperiment() expect(getState().showDetach.value).toBe(false) }) it('should be true after an experiment is selected', () => { useAppExperiment() useExperimentStore().select(makeExperiment()) expect(getState().showDetach.value).toBe(true) }) it('should be true while an id-only binding is still resolving', async () => { const fetch = stubExperimentFetch() const store = useExperimentStore() useAppExperiment() const pending = store.selectById(8) expect(getState().showDetach.value).toBe(true) fetch.resolveWith(makeExperiment({ id: 8 })) await pending }) it('should stay true after a failed resolve so the user can detach', async () => { const fetch = stubExperimentFetch() const store = useExperimentStore() useAppExperiment() const pending = store.selectById(8) fetch.rejectWith(new Error('Experiment not found')) await pending expect(getState().showDetach.value).toBe(true) }) it('should revert to false after the store is cleared', () => { const store = useExperimentStore() useAppExperiment() store.select(makeExperiment()) store.clear() expect(getState().showDetach.value).toBe(false) }) }) // ------------------------------------------------------------------------- // saveDisabled // ------------------------------------------------------------------------- describe('saveDisabled', () => { it('should be false by default when no saveDisabled option is provided', () => { useAppExperiment() expect(getState().saveDisabled.value).toBe(false) }) it('should unwrap a raw Ref', () => { const flag = ref(true) useAppExperiment({ saveDisabled: flag }) expect(getState().saveDisabled.value).toBe(true) flag.value = false expect(getState().saveDisabled.value).toBe(false) }) it('should unwrap a ComputedRef', () => { const source = ref(false) const flag = computed(() => source.value) useAppExperiment({ saveDisabled: flag }) expect(getState().saveDisabled.value).toBe(false) source.value = true expect(getState().saveDisabled.value).toBe(true) }) }) // ------------------------------------------------------------------------- // saveDisabledMessage // ------------------------------------------------------------------------- describe('saveDisabledMessage', () => { it('should be undefined when no saveDisabledMessage option is provided', () => { useAppExperiment() expect(getState().saveDisabledMessage.value).toBeUndefined() }) it('should unwrap a plain string', () => { useAppExperiment({ saveDisabledMessage: 'Cannot save right now' }) expect(getState().saveDisabledMessage.value).toBe('Cannot save right now') }) it('should unwrap a Ref', () => { const msg = ref('Initial message') useAppExperiment({ saveDisabledMessage: msg }) expect(getState().saveDisabledMessage.value).toBe('Initial message') msg.value = undefined expect(getState().saveDisabledMessage.value).toBeUndefined() }) it('should unwrap a ComputedRef', () => { const active = ref(true) const msg = computed(() => (active.value ? 'Disabled because active' : undefined)) useAppExperiment({ saveDisabledMessage: msg }) expect(getState().saveDisabledMessage.value).toBe('Disabled because active') active.value = false expect(getState().saveDisabledMessage.value).toBeUndefined() }) }) // ------------------------------------------------------------------------- // component bindings // ------------------------------------------------------------------------- describe('component bindings', () => { it('exposes live ExperimentPopover props from the provided state', async () => { const saveDisabled = ref(false) const saveDisabledMessage = ref('Ready to save') const onSave = vi.fn().mockResolvedValue('Saved') useAppExperiment({ onSave, saveDisabled, saveDisabledMessage }) const state = getState() useExperimentStore().select(makeExperiment({ id: 12, name: 'Dose response', status: 'ongoing', experiment_code: 'EXP-012', })) expect(state.popover.value).toMatchObject({ experimentName: 'Dose response', experimentCode: 'EXP-012', experimentStatus: 'ongoing', isResolving: false, showSave: true, showDetach: true, saveDisabled: false, saveDisabledMessage: 'Ready to save', saveLoading: false, }) expect(state.popover.value.error).toBeUndefined() saveDisabled.value = true saveDisabledMessage.value = 'Locked' expect(state.popover.value.saveDisabled).toBe(true) expect(state.popover.value.saveDisabledMessage).toBe('Locked') await state.handleSave() expect(state.popover.value.saveSuccessMessage).toBe('Saved') }) it('marks the popover binding as resolving with no name to show', async () => { const fetch = stubExperimentFetch() const store = useExperimentStore() useAppExperiment() const state = getState() const pending = store.selectById(12) expect(state.popover.value).toMatchObject({ experimentName: undefined, experimentCode: undefined, isResolving: true, showDetach: true, }) expect(state.popover.value.error).toBeUndefined() fetch.resolveWith(makeExperiment({ id: 12 })) await pending }) it('puts the resolution failure on the popover binding and keeps detach', async () => { const fetch = stubExperimentFetch() const store = useExperimentStore() useAppExperiment() const state = getState() const pending = store.selectById(12) fetch.rejectWith(new Error('Experiment not found')) await pending expect(state.popover.value).toMatchObject({ experimentName: undefined, isResolving: false, error: 'Experiment not found', showDetach: true, }) }) it('exposes live ExperimentSelectorModal props from state and return value', () => { const appExperiment = useAppExperiment() const state = getState() expect(state.selectorModal.value).toEqual({ modelValue: false, currentExperimentId: null, }) expect(appExperiment.selectorModal.value).toEqual(state.selectorModal.value) useExperimentStore().select(makeExperiment({ id: 77 })) state.openModal() expect(state.selectorModal.value).toEqual({ modelValue: true, currentExperimentId: 77, }) expect(appExperiment.popover.value.experimentName).toBe('Test Experiment') }) it('keeps the selector bound to the id after a failed resolve', async () => { const fetch = stubExperimentFetch() const store = useExperimentStore() useAppExperiment() const state = getState() const pending = store.selectById(77) fetch.rejectWith(new Error('Experiment not found')) await pending expect(state.selectorModal.value.currentExperimentId).toBe(77) }) }) // ------------------------------------------------------------------------- // Timer cleanup via onScopeDispose // ------------------------------------------------------------------------- describe('onScopeDispose cleanup', () => { it('should register an onScopeDispose callback', () => { useAppExperiment() expect(capturedScopeDispose).toBeTypeOf('function') }) it('should clear the success timer when the scope is disposed', async () => { const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout') const onSave = vi.fn().mockResolvedValue('Saved') useAppExperiment({ onSave }) const state = getState() await state.handleSave() // Timer is active — disposing the scope should cancel it capturedScopeDispose!() expect(clearTimeoutSpy).toHaveBeenCalled() }) it('should not call clearTimeout if no timer is active at disposal time', () => { const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout') useAppExperiment() // Dispose without ever triggering a save capturedScopeDispose!() expect(clearTimeoutSpy).not.toHaveBeenCalled() }) it('should not clear saveSuccessMessage after disposal if timer was cancelled', async () => { const onSave = vi.fn().mockResolvedValue('Will be cancelled') useAppExperiment({ onSave }) const state = getState() await state.handleSave() capturedScopeDispose!() // Advancing time past 3s should NOT clear message since timer was cancelled vi.advanceTimersByTime(3000) expect(state.saveSuccessMessage.value).toBe('Will be cancelled') }) }) }) describe('useAppExperiment browser storage isolation', () => { function installLegacySelection() { const storage: Record = { 'mint-current-experiment-id': '42' } 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) } beforeEach(() => { setActivePinia(createPinia()) pendingFetch = null installLegacySelection() vi.spyOn(axios.Axios.prototype, 'get').mockRejectedValue(new Error('Unexpected platform request')) }) afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() }) it('should ignore a legacy persisted id when nothing is selected', () => { const { experimentId } = useAppExperiment() expect(experimentId.value).toBeNull() }) it('should not request an experiment during shell setup', () => { useAppExperiment() expect(axios.Axios.prototype.get).not.toHaveBeenCalled() }) it('should leave an already-bound experiment alone', () => { useExperimentStore().select(makeExperiment({ id: 7, name: 'Already chosen' })) const { experimentName } = useAppExperiment() expect(experimentName.value).toBe('Already chosen') }) })