import { mount } from '@vue/test-utils' import { defineComponent, nextTick, shallowRef } from 'vue' import { describe, expect, it, vi } from 'vitest' import { combinePluginJobSources, usePluginJobCenter, type UsePluginJobCenterOptions, } from '../../composables/useJobsStatusTray' import type { JobStateInput, JobStatus } from '../../types/jobs' function job(status: JobStatus, id: string = status): JobStateInput { return { id, job_id: id, kind: 'analysis', name: id, status, progress: { current_file: 0, total_files: 0, current_filename: null, percent: status === 'running' ? 42 : 0, message: '', stage: status, }, queue_position: 0, warnings: [], error: null, output_path: null, save_path: null, session_id: null, folders: [], folder_path: null, user_id: '1', owner_user_id: '1', experiment_id: null, extraction_result_id: null, metadata: {}, created_at: '2026-07-14T10:00:00+00:00', started_at: null, completed_at: null, } } function mountComposable(options: UsePluginJobCenterOptions) { let tray!: ReturnType const wrapper = mount(defineComponent({ setup() { tray = usePluginJobCenter(options) return () => null }, })) return { tray, wrapper } } describe('usePluginJobCenter', () => { it('combines live source jobs and fans out or routes source actions', async () => { const firstJobs = shallowRef([job('running', 'first')]) const secondJobs = shallowRef([job('completed', 'second')]) const firstRefresh = vi.fn(async () => firstJobs.value) const secondRefresh = vi.fn(async () => secondJobs.value) const secondCancel = vi.fn(async (jobId: string) => job('cancelled', jobId)) const firstClear = vi.fn(async () => 1) const secondClear = vi.fn(async () => 2) const firstDispose = vi.fn() const secondDispose = vi.fn() const combined = combinePluginJobSources([ { jobs: firstJobs, refreshJobs: firstRefresh, clearFinishedJobs: firstClear, dispose: firstDispose, }, { jobs: secondJobs, refreshJobs: secondRefresh, cancelJob: secondCancel, clearFinishedJobs: secondClear, dispose: secondDispose, }, ]) expect(combined.jobs.value.map(item => item.job_id)).toEqual(['first', 'second']) secondJobs.value = [job('running', 'replacement')] await nextTick() expect(combined.jobs.value.map(item => item.job_id)).toEqual(['first', 'replacement']) await combined.refreshJobs?.() await combined.cancelJob?.('replacement') await expect(combined.clearFinishedJobs?.()).resolves.toBe(3) combined.dispose?.() expect({ refreshes: [firstRefresh.mock.calls.length, secondRefresh.mock.calls.length], cancelled: secondCancel.mock.calls[0]?.[0], disposed: [firstDispose.mock.calls.length, secondDispose.mock.calls.length], }).toEqual({ refreshes: [1, 1], cancelled: 'replacement', disposed: [1, 1], }) }) it('retains every failure when disposing combined sources', () => { const firstFailure = new Error('first dispose failed') const secondFailure = { source: 'second', reason: 'dispose failed' } const finalDispose = vi.fn() const combined = combinePluginJobSources([ { jobs: [], dispose: () => { throw firstFailure }, }, { jobs: [], dispose: () => { throw secondFailure }, }, { jobs: [], dispose: finalDispose }, ]) let failure: unknown try { combined.dispose?.() } catch (cause) { failure = cause } expect(failure).toMatchObject({ name: 'PluginJobSourceDisposeError', message: 'Failed to dispose plugin job sources', cause: firstFailure, errors: [firstFailure, secondFailure], }) expect(finalDispose).toHaveBeenCalledOnce() }) it('rejects duplicate ids instead of routing to an arbitrary combined source', async () => { const firstCancel = vi.fn(async () => undefined) const secondCancel = vi.fn(async () => undefined) const combined = combinePluginJobSources([ { jobs: [job('running', 'duplicate')], cancelJob: firstCancel }, { jobs: [job('queued', 'duplicate')], cancelJob: secondCancel }, ]) expect(() => combined.jobs.value).toThrow('Duplicate plugin job id') await expect(combined.cancelJob?.('duplicate')).rejects.toThrow('Duplicate plugin job id') expect([firstCancel.mock.calls.length, secondCancel.mock.calls.length]).toEqual([0, 0]) }) it('does not expose a partial bulk clear across combined sources', () => { const combined = combinePluginJobSources([ { jobs: [job('completed', 'first')], clearFinishedJobs: async () => 1 }, { jobs: [job('completed', 'second')] }, ]) expect(combined.clearFinishedJobs).toBeUndefined() }) it('does not partially delete finished jobs when one combined source cannot delete', async () => { const firstDelete = vi.fn(async () => true) const combined = combinePluginJobSources([ { jobs: [job('completed', 'first')], deleteJob: firstDelete }, { jobs: [job('completed', 'second')] }, ]) let center!: ReturnType const wrapper = mount(defineComponent({ setup() { center = usePluginJobCenter({ source: combined, loadOnMount: false }) return () => null }, })) expect(combined.deleteJob).toBeUndefined() expect(center.canClearFinished.value).toBe(false) await expect(center.clearFinished()).resolves.toBe(false) expect(firstDelete).not.toHaveBeenCalled() wrapper.unmount() }) it('derives jobs directly from a canonical source ref', async () => { const sourceJobs = shallowRef([job('queued', 'first')]) let center!: ReturnType const wrapper = mount(defineComponent({ setup() { center = usePluginJobCenter({ source: { jobs: sourceJobs }, loadOnMount: false, }) return () => null }, })) sourceJobs.value = [job('running', 'replacement')] await nextTick() expect(center.jobs.value.map(item => item.job_id)).toEqual(['replacement']) wrapper.unmount() }) it('delegates actions by id and leaves source mutation with the source', async () => { const sourceJobs = shallowRef([job('running', 'fit')]) const cancelJob = vi.fn(async (jobId: string) => { sourceJobs.value = [job('cancelled', jobId)] return sourceJobs.value[0] }) let center!: ReturnType const wrapper = mount(defineComponent({ setup() { center = usePluginJobCenter({ source: { jobs: sourceJobs, cancelJob }, loadOnMount: false, }) return () => null }, })) await center.cancelJob(center.jobs.value[0]!) expect([cancelJob.mock.calls[0]?.[0], center.jobs.value[0]?.status]).toEqual([ 'fit', 'cancelled', ]) wrapper.unmount() }) it('keeps awaiting input jobs in the active group', () => { const { tray } = mountComposable({ source: { jobs: [job('awaiting_input')] }, loadOnMount: false, }) expect(tray.activeJobs.value.map(item => item.job_id)).toEqual(['awaiting_input']) }) it('does not call a source cancel action when the capability is disabled', async () => { const cancelJob = vi.fn() const { tray } = mountComposable({ source: { jobs: [{ ...job('running'), can_cancel: false }], cancelJob, }, loadOnMount: false, }) await tray.cancelJob(tray.jobs.value[0]!) expect(cancelJob).not.toHaveBeenCalled() }) it('refreshes through the source without mirroring its jobs', async () => { const sourceJobs = shallowRef([job('running', 'old')]) const refreshJobs = vi.fn(async () => { sourceJobs.value = [job('queued', 'new')] return sourceJobs.value }) const { tray } = mountComposable({ source: { jobs: sourceJobs, refreshJobs }, loadOnMount: false, }) await tray.refresh() await nextTick() expect([refreshJobs.mock.calls.length, tray.jobs.value.map(item => item.job_id)]).toEqual([ 1, ['new'], ]) }) it('does not include active deletable jobs in bulk clear targets', () => { const { tray } = mountComposable({ source: { jobs: [{ ...job('awaiting_input'), can_delete: true }] }, loadOnMount: false, }) expect(tray.deletableJobs.value).toEqual([]) }) })