mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-29 20:17:36 +08:00
fix(dashboard): avoid duplicate status card refreshes (#684)
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import type { ScheduleInfo, ScheduleProgress } from '@/api/types'
|
||||
import {
|
||||
getScheduleName,
|
||||
getScheduleNextRunText,
|
||||
getScheduleProvider,
|
||||
getScheduleStatusText,
|
||||
isScheduleRunning,
|
||||
isScheduleWaiting,
|
||||
useScheduleProgress,
|
||||
} from '@/composables/useScheduleProgress'
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
useDataRefresh: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useBackground', () => ({
|
||||
useBackground: () => ({
|
||||
useDataRefresh: mocks.useDataRefresh,
|
||||
}),
|
||||
}))
|
||||
|
||||
function createSchedule(overrides: Partial<ScheduleInfo> = {}): ScheduleInfo {
|
||||
return {
|
||||
id: 'schedule-1',
|
||||
name: '原始名称',
|
||||
provider: '原始提供者',
|
||||
status: '空闲',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
|
||||
return { promise, reject, resolve }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.useDataRefresh.mockReset()
|
||||
})
|
||||
|
||||
describe('schedule display helpers', () => {
|
||||
it.each([
|
||||
['列表状态', { status: '正在运行' }],
|
||||
['列表进度开关', { progress_enable: true }],
|
||||
['进度详情开关', { progress_detail: { enable: true } }],
|
||||
['进度详情状态', { progress_detail: { status: 'running' } }],
|
||||
] satisfies Array<[string, Partial<ScheduleInfo>]>)('recognizes the running signal from %s', (_label, overrides) => {
|
||||
expect(isScheduleRunning(createSchedule(overrides))).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes waiting only when no running signal is active', () => {
|
||||
expect(isScheduleWaiting(createSchedule({ status: '等待' }))).toBe(true)
|
||||
expect(isScheduleWaiting(createSchedule({ progress_enable: true, status: '等待' }))).toBe(false)
|
||||
expect(isScheduleWaiting(createSchedule({ status: '空闲' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers localized display fields and falls back to their raw values', () => {
|
||||
const localized = createSchedule({
|
||||
name_i18n: '本地化名称',
|
||||
next_run: 'raw next run',
|
||||
next_run_i18n: '本地化下次运行',
|
||||
provider_i18n: '本地化提供者',
|
||||
status_i18n: '本地化状态',
|
||||
})
|
||||
const raw = createSchedule({ next_run: 'raw next run' })
|
||||
|
||||
expect([
|
||||
getScheduleName(localized),
|
||||
getScheduleProvider(localized),
|
||||
getScheduleStatusText(localized),
|
||||
getScheduleNextRunText(localized),
|
||||
]).toEqual(['本地化名称', '本地化提供者', '本地化状态', '本地化下次运行'])
|
||||
expect([
|
||||
getScheduleName(raw),
|
||||
getScheduleProvider(raw),
|
||||
getScheduleStatusText(raw),
|
||||
getScheduleNextRunText(raw),
|
||||
]).toEqual(['原始名称', '原始提供者', '空闲', 'raw next run'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('schedule progress refresh', () => {
|
||||
it('registers the requested refresh identity, callback, interval, and immediate mode', () => {
|
||||
const progress = useScheduleProgress(ref([]), 'dashboard-schedule-progress')
|
||||
|
||||
expect(mocks.useDataRefresh).toHaveBeenCalledOnce()
|
||||
expect(mocks.useDataRefresh).toHaveBeenCalledWith(
|
||||
'dashboard-schedule-progress',
|
||||
progress.refreshRunningProgress,
|
||||
1000,
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('requests only running schedules and keeps fulfilled results when a sibling request fails', async () => {
|
||||
const runningByStatus = createSchedule({ id: 'status-running', progress: 5, status: '正在运行' })
|
||||
const runningByDetail = createSchedule({
|
||||
id: 'detail-running',
|
||||
progress: 15,
|
||||
progress_detail: { status: 'running' },
|
||||
})
|
||||
const waiting = createSchedule({ id: 'waiting', status: '等待' })
|
||||
const idle = createSchedule({ id: 'idle' })
|
||||
const schedules = ref([runningByStatus, runningByDetail, waiting, idle])
|
||||
const progress = useScheduleProgress(schedules, 'focused-refresh')
|
||||
|
||||
mocks.apiGet.mockImplementation((path: string) => {
|
||||
if (path.includes('status-running')) return Promise.resolve({ text: '已完成一半', value: 50 })
|
||||
return Promise.reject(new Error('detail progress unavailable'))
|
||||
})
|
||||
|
||||
await expect(progress.refreshRunningProgress()).resolves.toBeUndefined()
|
||||
|
||||
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('dashboard/schedule/status-running/progress', { feedback: 'silent' })
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('dashboard/schedule/detail-running/progress', { feedback: 'silent' })
|
||||
expect(progress.getScheduleProgressValue(runningByStatus)).toBe(50)
|
||||
expect(progress.getScheduleProgressText(runningByStatus)).toBe('已完成一半')
|
||||
expect(progress.getScheduleProgressValue(runningByDetail)).toBe(15)
|
||||
})
|
||||
|
||||
it('discards a late response when its schedule stops while the request is pending', async () => {
|
||||
const request = deferred<ScheduleProgress>()
|
||||
const running = createSchedule({ id: 'late-result', progress: 7, progress_text: '列表进度', status: '正在运行' })
|
||||
const schedules = ref([running])
|
||||
const progress = useScheduleProgress(schedules, 'late-result-refresh')
|
||||
mocks.apiGet.mockReturnValueOnce(request.promise)
|
||||
|
||||
const refresh = progress.refreshRunningProgress()
|
||||
schedules.value = [createSchedule({ id: 'late-result', progress: 7, progress_text: '列表进度' })]
|
||||
request.resolve({ text: '迟到结果', value: 88 })
|
||||
await refresh
|
||||
|
||||
expect(progress.getScheduleProgressValue(schedules.value[0])).toBe(7)
|
||||
expect(progress.getScheduleProgressText(schedules.value[0])).toBe('列表进度')
|
||||
})
|
||||
|
||||
it('removes cached progress for schedules that have stopped', async () => {
|
||||
const first = createSchedule({ id: 'first', progress: 1, progress_text: '第一项列表进度', status: '正在运行' })
|
||||
const stopped = createSchedule({ id: 'stopped', progress: 2, progress_text: '停止后列表进度', status: '正在运行' })
|
||||
const schedules = ref([first, stopped])
|
||||
const progress = useScheduleProgress(schedules, 'cache-cleanup-refresh')
|
||||
mocks.apiGet.mockResolvedValueOnce({ text: '第一项远端进度', value: 30 }).mockResolvedValueOnce({
|
||||
text: '即将过期的远端进度',
|
||||
value: 60,
|
||||
})
|
||||
|
||||
await progress.refreshRunningProgress()
|
||||
expect(progress.getScheduleProgressText(stopped)).toBe('即将过期的远端进度')
|
||||
|
||||
const stoppedNow = createSchedule({ id: 'stopped', progress: 2, progress_text: '停止后列表进度' })
|
||||
schedules.value = [first, stoppedNow]
|
||||
mocks.apiGet.mockResolvedValueOnce({ text: '第一项更新进度', value: 40 })
|
||||
await progress.refreshRunningProgress()
|
||||
|
||||
expect(progress.getScheduleProgressValue(stoppedNow)).toBe(2)
|
||||
expect(progress.getScheduleProgressText(stoppedNow)).toBe('停止后列表进度')
|
||||
})
|
||||
})
|
||||
|
||||
describe('schedule progress presentation', () => {
|
||||
it('clamps list and refreshed progress values to the 0..100 range', async () => {
|
||||
const belowRange = createSchedule({ id: 'below-range', progress: -20 })
|
||||
const aboveRange = createSchedule({ id: 'above-range', progress: 140, status: '正在运行' })
|
||||
const schedules = ref([belowRange, aboveRange])
|
||||
const progress = useScheduleProgress(schedules, 'clamp-refresh')
|
||||
|
||||
expect(progress.getScheduleProgressValue(belowRange)).toBe(0)
|
||||
expect(progress.getScheduleProgressValue(aboveRange)).toBe(100)
|
||||
|
||||
mocks.apiGet.mockResolvedValueOnce({ value: -1 })
|
||||
await progress.refreshRunningProgress()
|
||||
expect(progress.getScheduleProgressValue(aboveRange)).toBe(0)
|
||||
|
||||
mocks.apiGet.mockResolvedValueOnce({ value: 101 })
|
||||
await progress.refreshRunningProgress()
|
||||
expect(progress.getScheduleProgressValue(aboveRange)).toBe(100)
|
||||
})
|
||||
|
||||
it('uses localized progress text first and falls back through remote and schedule text', async () => {
|
||||
const localizedRemote = createSchedule({ id: 'localized-remote', status: '正在运行' })
|
||||
const rawRemote = createSchedule({ id: 'raw-remote', status: '正在运行' })
|
||||
const localizedSchedule = createSchedule({
|
||||
id: 'localized-schedule',
|
||||
progress_text: '原始列表进度',
|
||||
progress_text_i18n: '本地化列表进度',
|
||||
status: '正在运行',
|
||||
})
|
||||
const rawSchedule = createSchedule({ id: 'raw-schedule', progress_text: '原始列表进度', status: '正在运行' })
|
||||
const empty = createSchedule({ id: 'empty', status: '正在运行' })
|
||||
const schedules = ref([localizedRemote, rawRemote, localizedSchedule, rawSchedule, empty])
|
||||
const progress = useScheduleProgress(schedules, 'text-fallback-refresh')
|
||||
mocks.apiGet
|
||||
.mockResolvedValueOnce({ text: '原始远端进度', text_i18n: '本地化远端进度' })
|
||||
.mockResolvedValueOnce({ text: '原始远端进度' })
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
|
||||
await progress.refreshRunningProgress()
|
||||
|
||||
expect(progress.getScheduleProgressText(localizedRemote)).toBe('本地化远端进度')
|
||||
expect(progress.getScheduleProgressText(rawRemote)).toBe('原始远端进度')
|
||||
expect(progress.getScheduleProgressText(localizedSchedule)).toBe('本地化列表进度')
|
||||
expect(progress.getScheduleProgressText(rawSchedule)).toBe('原始列表进度')
|
||||
expect(progress.getScheduleProgressText(empty)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import type { Storage } from '@/api/types'
|
||||
import storageImage from '@images/misc/storage.png'
|
||||
import { formatDashboardFileSize, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -53,13 +54,9 @@ async function getStorage() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getStorage()
|
||||
})
|
||||
const { refresh: refreshStorage } = useKeepAliveRefresh(getStorage)
|
||||
|
||||
onActivated(() => {
|
||||
getStorage()
|
||||
})
|
||||
onMounted(refreshStorage)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -5,6 +5,7 @@ import noImage from '@images/no-image.jpeg'
|
||||
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatMusicAudioSpecs } from '@/utils/music'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -40,8 +41,9 @@ function getImportMeta(item: TransferHistory) {
|
||||
return values.filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
onMounted(loadRecentImports)
|
||||
onActivated(loadRecentImports)
|
||||
const { refresh: refreshRecentImports } = useKeepAliveRefresh(loadRecentImports)
|
||||
|
||||
onMounted(refreshRecentImports)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import AnalyticsNetwork from '@/views/dashboard/AnalyticsNetwork.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import { defineComponent, h, type PropType } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
interface NetworkSeries {
|
||||
name: string
|
||||
data: number[]
|
||||
}
|
||||
|
||||
interface NetworkChartOptions {
|
||||
yaxis: {
|
||||
labels: {
|
||||
formatter: (value: number) => string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface RefreshRegistration {
|
||||
id: string
|
||||
callback: () => Promise<void>
|
||||
interval: number
|
||||
immediate: boolean
|
||||
refresh: ReturnType<typeof vi.fn<() => Promise<void>>>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
chartOptions: undefined as NetworkChartOptions | undefined,
|
||||
keepAliveHandler: undefined as (() => Promise<void>) | undefined,
|
||||
refreshRegistrations: [] as RefreshRegistration[],
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: (...args: unknown[]) => mocks.apiGet(...args) },
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useBackground', () => ({
|
||||
useBackground: () => ({
|
||||
useDataRefresh: (id: string, callback: () => Promise<void>, interval: number, immediate: boolean) => {
|
||||
const refresh = vi.fn(callback)
|
||||
mocks.refreshRegistrations.push({ id, callback, interval, immediate, refresh })
|
||||
|
||||
return { loading: { value: false }, refresh, stop: vi.fn() }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useKeepAliveRefresh', () => ({
|
||||
useKeepAliveRefresh: (handler: () => Promise<void>) => {
|
||||
mocks.keepAliveHandler = handler
|
||||
return { refresh: handler }
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useDashboardMotion', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useDashboardMotion')>()
|
||||
return {
|
||||
...actual,
|
||||
useAnimatedDashboardNumber: (source: { value: number }) => source,
|
||||
}
|
||||
})
|
||||
|
||||
const ApexChartStub = defineComponent({
|
||||
name: 'VApexChart',
|
||||
props: {
|
||||
height: String,
|
||||
options: {
|
||||
type: Object as PropType<NetworkChartOptions>,
|
||||
required: true,
|
||||
},
|
||||
series: {
|
||||
type: Array as PropType<NetworkSeries[]>,
|
||||
required: true,
|
||||
},
|
||||
type: String,
|
||||
},
|
||||
setup: props => () => {
|
||||
mocks.chartOptions = props.options
|
||||
|
||||
return h('div', {
|
||||
'data-testid': 'network-series',
|
||||
'data-series': JSON.stringify(props.series),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function getRefreshRegistration() {
|
||||
const registration = mocks.refreshRegistrations[0]
|
||||
if (!registration) throw new Error('未注册网络状态刷新任务')
|
||||
return registration
|
||||
}
|
||||
|
||||
function getSeries() {
|
||||
const serialized = screen.getByTestId('network-series').getAttribute('data-series')
|
||||
if (!serialized) throw new Error('图表测试探针未收到序列数据')
|
||||
return JSON.parse(serialized) as NetworkSeries[]
|
||||
}
|
||||
|
||||
async function renderNetwork(props: { allowRefresh?: boolean } = {}) {
|
||||
return renderWithProviders(AnalyticsNetwork, {
|
||||
props,
|
||||
global: {
|
||||
stubs: { VApexChart: ApexChartStub },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AnalyticsNetwork', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.chartOptions = undefined
|
||||
mocks.keepAliveHandler = undefined
|
||||
mocks.refreshRegistrations.length = 0
|
||||
})
|
||||
|
||||
it('registers the expected refresh policy and skips reads when refresh is disabled', async () => {
|
||||
await renderNetwork({ allowRefresh: false })
|
||||
|
||||
const registration = getRefreshRegistration()
|
||||
expect(registration).toMatchObject({
|
||||
id: 'dashboard-network',
|
||||
interval: 2000,
|
||||
immediate: true,
|
||||
})
|
||||
expect(mocks.keepAliveHandler).toBe(registration.refresh)
|
||||
|
||||
await registration.refresh()
|
||||
|
||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||
expect(getSeries().map(item => item.data)).toEqual([[0], [0]])
|
||||
})
|
||||
|
||||
it('normalizes rates and updates current values and series through refresh and KeepAlive', async () => {
|
||||
mocks.apiGet.mockResolvedValueOnce(['2048', 'not-a-number']).mockResolvedValueOnce([3072, 1024 ** 2])
|
||||
await renderNetwork()
|
||||
|
||||
const registration = getRefreshRegistration()
|
||||
await registration.refresh()
|
||||
|
||||
expect(mocks.apiGet).toHaveBeenNthCalledWith(1, 'dashboard/network', {
|
||||
feedback: 'silent',
|
||||
skipNavigationCancellation: true,
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/上行 2\.00 KB\/s/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/下行 0\.00 B\/s/)).toBeInTheDocument()
|
||||
expect(mocks.chartOptions?.yaxis.labels.formatter(1024)).toContain('KB/s')
|
||||
expect(getSeries().map(item => item.data)).toEqual([
|
||||
[0, 2048],
|
||||
[0, 0],
|
||||
])
|
||||
})
|
||||
|
||||
if (!mocks.keepAliveHandler) throw new Error('未注册 KeepAlive 刷新回调')
|
||||
await mocks.keepAliveHandler()
|
||||
|
||||
expect(mocks.apiGet).toHaveBeenNthCalledWith(2, 'dashboard/network', {
|
||||
feedback: 'silent',
|
||||
skipNavigationCancellation: true,
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/上行 3\.00 KB\/s/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/下行 1\.00 MB\/s/)).toBeInTheDocument()
|
||||
expect(getSeries().map(item => item.data)).toEqual([
|
||||
[0, 2048, 3072],
|
||||
[0, 0, 1024 ** 2],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps only the latest 30 samples in both network series', async () => {
|
||||
let sample = 0
|
||||
mocks.apiGet.mockImplementation(async () => {
|
||||
sample += 1
|
||||
return [sample, sample * 10]
|
||||
})
|
||||
await renderNetwork()
|
||||
|
||||
const { refresh } = getRefreshRegistration()
|
||||
for (let index = 0; index < 30; index += 1) await refresh()
|
||||
|
||||
await waitFor(() => {
|
||||
const [upload, download] = getSeries()
|
||||
expect(upload.data).toHaveLength(30)
|
||||
expect(download.data).toHaveLength(30)
|
||||
expect(upload.data).toEqual(Array.from({ length: 30 }, (_, index) => index + 1))
|
||||
expect(download.data).toEqual(Array.from({ length: 30 }, (_, index) => (index + 1) * 10))
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the last successful rates and series when a refresh fails', async () => {
|
||||
const error = new Error('network usage unavailable')
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
mocks.apiGet.mockResolvedValueOnce([1024, 2048]).mockRejectedValueOnce(error)
|
||||
await renderNetwork()
|
||||
|
||||
const { refresh } = getRefreshRegistration()
|
||||
await refresh()
|
||||
await refresh()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/上行 1\.00 KB\/s/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/下行 2\.00 KB\/s/)).toBeInTheDocument()
|
||||
expect(getSeries().map(item => item.data)).toEqual([
|
||||
[0, 1024],
|
||||
[0, 2048],
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,277 @@
|
||||
import type { ScheduleInfo, TransferQueue } from '@/api/types'
|
||||
import i18n from '@/plugins/i18n'
|
||||
import AnalyticsScheduler from '@/views/dashboard/AnalyticsScheduler.vue'
|
||||
import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
|
||||
import type { Ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
interface RefreshRegistration {
|
||||
callback: () => Promise<void>
|
||||
id: string
|
||||
immediate: boolean
|
||||
interval: number
|
||||
}
|
||||
|
||||
type DashboardTransferQueue = Pick<TransferQueue, 'season'> & {
|
||||
media: Pick<TransferQueue['media'], 'media_id' | 'media_source' | 'title' | 'title_year'>
|
||||
tasks: Array<Pick<TransferQueue['tasks'][number], 'state'>>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
getScheduleProgressText: vi.fn(),
|
||||
getScheduleProgressValue: vi.fn(),
|
||||
refreshRegistrations: [] as RefreshRegistration[],
|
||||
scheduleSource: undefined as Ref<ScheduleInfo[]> | undefined,
|
||||
useScheduleProgress: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: createDataApiMock({
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useBackground', () => ({
|
||||
useBackground: () => ({
|
||||
useDataRefresh: (id: string, callback: () => Promise<void>, interval: number, immediate: boolean) => {
|
||||
mocks.refreshRegistrations.push({ callback, id, immediate, interval })
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useScheduleProgress', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useScheduleProgress')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useScheduleProgress: (schedules: Ref<ScheduleInfo[]>, refreshId: string) => {
|
||||
mocks.useScheduleProgress(schedules, refreshId)
|
||||
mocks.scheduleSource = schedules
|
||||
expect(refreshId).toBe('dashboard-scheduler-progress')
|
||||
|
||||
return {
|
||||
getScheduleProgressText: mocks.getScheduleProgressText,
|
||||
getScheduleProgressValue: mocks.getScheduleProgressValue,
|
||||
refreshRunningProgress: vi.fn(),
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function mountScheduler(props: { allowRefresh?: boolean } = {}) {
|
||||
i18n.global.locale.value = 'zh-CN'
|
||||
|
||||
return shallowMount(AnalyticsScheduler, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
renderStubDefaultSlot: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function dashboardRefresh() {
|
||||
const registration = mocks.refreshRegistrations.find(item => item.id === 'dashboard-scheduler')
|
||||
if (!registration) throw new Error('dashboard scheduler refresh was not registered')
|
||||
|
||||
return registration
|
||||
}
|
||||
|
||||
function listItems(wrapper: VueWrapper) {
|
||||
return wrapper.findAllComponents({ name: 'VListItem' })
|
||||
}
|
||||
|
||||
function listItemByText(wrapper: VueWrapper, text: string) {
|
||||
const item = listItems(wrapper).find(candidate => candidate.text().includes(text))
|
||||
if (!item) throw new Error(`list item not found: ${text}`)
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
describe('AnalyticsScheduler', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.getScheduleProgressText.mockReset()
|
||||
mocks.getScheduleProgressText.mockReturnValue('')
|
||||
mocks.getScheduleProgressValue.mockReset()
|
||||
mocks.getScheduleProgressValue.mockReturnValue(0)
|
||||
mocks.refreshRegistrations.splice(0)
|
||||
mocks.scheduleSource = undefined
|
||||
mocks.useScheduleProgress.mockReset()
|
||||
})
|
||||
|
||||
it('registers its refresh contract and skips both requests while refresh is disabled', async () => {
|
||||
mountScheduler({ allowRefresh: false })
|
||||
|
||||
expect(mocks.useScheduleProgress).toHaveBeenCalledOnce()
|
||||
expect(dashboardRefresh()).toMatchObject({
|
||||
id: 'dashboard-scheduler',
|
||||
interval: 3000,
|
||||
immediate: true,
|
||||
})
|
||||
|
||||
await dashboardRefresh().callback()
|
||||
|
||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads the schedule and transfer queue through the registered refresh callback', async () => {
|
||||
const schedules = [
|
||||
{
|
||||
id: 'cookiecloud',
|
||||
name: 'CookieCloud',
|
||||
provider: '内置服务',
|
||||
status: '等待',
|
||||
},
|
||||
] satisfies ScheduleInfo[]
|
||||
const queue = [] satisfies DashboardTransferQueue[]
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === 'dashboard/schedule') return schedules
|
||||
if (url === 'transfer/queue') return queue
|
||||
throw new Error(`Unexpected GET ${url}`)
|
||||
})
|
||||
const wrapper = mountScheduler()
|
||||
|
||||
await dashboardRefresh().callback()
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.apiGet).toHaveBeenNthCalledWith(1, 'dashboard/schedule')
|
||||
expect(mocks.apiGet).toHaveBeenNthCalledWith(2, 'transfer/queue')
|
||||
expect(mocks.scheduleSource?.value).toEqual(schedules)
|
||||
expect(wrapper.text()).toContain('CookieCloud')
|
||||
})
|
||||
|
||||
it('sorts running schedules first and applies localized and empty fallbacks', async () => {
|
||||
mocks.getScheduleProgressText.mockImplementation((schedule: ScheduleInfo) =>
|
||||
schedule.id === 'running' ? '已处理 2 项' : '',
|
||||
)
|
||||
mocks.getScheduleProgressValue.mockReturnValue(40)
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === 'dashboard/schedule') {
|
||||
return [
|
||||
{
|
||||
id: 'waiting-localized',
|
||||
name: 'Waiting raw',
|
||||
name_i18n: '本地化等待任务',
|
||||
provider: 'Provider raw',
|
||||
provider_i18n: '本地化提供者',
|
||||
status: '等待',
|
||||
status_i18n: '已排队',
|
||||
},
|
||||
{
|
||||
id: 'running',
|
||||
name: 'Running raw',
|
||||
name_i18n: '本地化运行任务',
|
||||
provider: 'Running provider',
|
||||
status: '正在运行',
|
||||
status_i18n: '后端运行文案',
|
||||
},
|
||||
{
|
||||
id: 'fallback',
|
||||
name: '',
|
||||
provider: '',
|
||||
status: '',
|
||||
},
|
||||
] satisfies ScheduleInfo[]
|
||||
}
|
||||
if (url === 'transfer/queue') return []
|
||||
throw new Error(`Unexpected GET ${url}`)
|
||||
})
|
||||
const wrapper = mountScheduler()
|
||||
|
||||
await dashboardRefresh().callback()
|
||||
await flushPromises()
|
||||
|
||||
const items = listItems(wrapper)
|
||||
expect(items.map(item => item.text())).toEqual([
|
||||
expect.stringContaining('本地化运行任务'),
|
||||
expect.stringContaining('本地化等待任务'),
|
||||
expect.stringContaining('后台任务'),
|
||||
])
|
||||
expect(items[0].text()).toContain('已处理 2 项')
|
||||
expect(items[0].text()).toContain('进行中')
|
||||
expect(items[0].text()).not.toContain('后端运行文案')
|
||||
expect(items[1].text()).toContain('本地化提供者')
|
||||
expect(items[1].text()).toContain('已排队')
|
||||
expect(items[2].text()).toContain('等待中')
|
||||
})
|
||||
|
||||
it('derives transfer progress and stable keys from queue identity', async () => {
|
||||
const runningTransfer = {
|
||||
media: { media_source: 'tmdb', media_id: '42', title_year: '运行中的电影 (2026)' },
|
||||
season: 2,
|
||||
tasks: [{ state: 'completed' }, { state: 'running' }, { state: 'waiting' }],
|
||||
} satisfies DashboardTransferQueue
|
||||
const completedTransfer = {
|
||||
media: { media_source: 'douban', media_id: 'movie-7', title: '已完成的电影' },
|
||||
tasks: [{ state: 'completed' }, { state: 'completed' }],
|
||||
} satisfies DashboardTransferQueue
|
||||
let queue = [runningTransfer, completedTransfer]
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === 'dashboard/schedule') return []
|
||||
if (url === 'transfer/queue') return queue
|
||||
throw new Error(`Unexpected GET ${url}`)
|
||||
})
|
||||
const wrapper = mountScheduler()
|
||||
|
||||
await dashboardRefresh().callback()
|
||||
await flushPromises()
|
||||
|
||||
const runningItem = listItemByText(wrapper, '运行中的电影 (2026)')
|
||||
const completedItem = listItemByText(wrapper, '已完成的电影')
|
||||
expect(runningItem.vm.$.vnode.key).toBe('transfer-tmdb-42-2')
|
||||
expect(runningItem.text()).toContain('1 / 3 个文件')
|
||||
expect(runningItem.text()).toContain('进行中')
|
||||
expect(runningItem.findComponent({ name: 'VProgressLinear' }).props('modelValue')).toBe(33)
|
||||
expect(completedItem.vm.$.vnode.key).toBe('transfer-douban-movie-7-')
|
||||
expect(completedItem.text()).toContain('2 / 2 个文件')
|
||||
expect(completedItem.text()).toContain('等待中')
|
||||
expect(completedItem.findComponent({ name: 'VProgressLinear' }).exists()).toBe(false)
|
||||
|
||||
queue = [completedTransfer, runningTransfer]
|
||||
await dashboardRefresh().callback()
|
||||
await flushPromises()
|
||||
|
||||
expect(listItemByText(wrapper, '运行中的电影 (2026)').vm.$.vnode.key).toBe('transfer-tmdb-42-2')
|
||||
})
|
||||
|
||||
it('renders the empty state and preserves the last successful state after a refresh failure', async () => {
|
||||
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
let refresh = 0
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (refresh === 0) return []
|
||||
if (refresh === 1 && url === 'dashboard/schedule') {
|
||||
return [
|
||||
{
|
||||
id: 'kept',
|
||||
name: '保留的后台任务',
|
||||
provider: '内置服务',
|
||||
status: '等待',
|
||||
},
|
||||
] satisfies ScheduleInfo[]
|
||||
}
|
||||
if (refresh === 1 && url === 'transfer/queue') return []
|
||||
throw new Error('remote unavailable')
|
||||
})
|
||||
const wrapper = mountScheduler()
|
||||
|
||||
await dashboardRefresh().callback()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('没有后台服务')
|
||||
|
||||
refresh = 1
|
||||
await dashboardRefresh().callback()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('保留的后台任务')
|
||||
expect(wrapper.text()).not.toContain('没有后台服务')
|
||||
|
||||
refresh = 2
|
||||
await dashboardRefresh().callback()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('保留的后台任务')
|
||||
expect(wrapper.text()).not.toContain('没有后台服务')
|
||||
expect(consoleLog).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import AnalyticsStorage from '@/views/dashboard/AnalyticsStorage.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: createDataApiMock({
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useDashboardMotion', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useDashboardMotion')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useAnimatedDashboardNumber: (source: { value: number }) => source,
|
||||
}
|
||||
})
|
||||
|
||||
const KeepAliveHarness = defineComponent({
|
||||
components: { AnalyticsStorage },
|
||||
setup() {
|
||||
const active = ref(true)
|
||||
|
||||
return { active }
|
||||
},
|
||||
template: `
|
||||
<button type="button" @click="active = false">停用存储卡片</button>
|
||||
<button type="button" @click="active = true">启用存储卡片</button>
|
||||
<KeepAlive><AnalyticsStorage v-if="active" /></KeepAlive>
|
||||
`,
|
||||
})
|
||||
|
||||
describe('analytics storage', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset()
|
||||
})
|
||||
|
||||
it('normalizes numeric API values and renders total, used percent and available storage', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
total_storage: String(2 * 1024 ** 3),
|
||||
used_storage: String(512 * 1024 ** 2),
|
||||
},
|
||||
})
|
||||
|
||||
await renderWithProviders(AnalyticsStorage)
|
||||
|
||||
expect(await screen.findByText('2.00 GB')).toBeInTheDocument()
|
||||
expect(screen.getByText('已使用 25.0%')).toBeInTheDocument()
|
||||
expect(screen.getByText('可用 1.50 GB / 总容量 2.00 GB')).toBeInTheDocument()
|
||||
expect(mocks.apiGet).toHaveBeenCalledOnce()
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('dashboard/storage')
|
||||
})
|
||||
|
||||
it('falls back to zero for invalid or null API values', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
total_storage: 'invalid',
|
||||
used_storage: null,
|
||||
},
|
||||
})
|
||||
|
||||
await renderWithProviders(AnalyticsStorage)
|
||||
|
||||
expect(await screen.findByText('已使用 0.0%')).toBeInTheDocument()
|
||||
expect(screen.getByText('可用 0.00 B / 总容量 0.00 B')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clamps over-capacity usage to 100 percent and never shows negative available storage', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
total_storage: 1024,
|
||||
used_storage: 2048,
|
||||
},
|
||||
})
|
||||
|
||||
await renderWithProviders(AnalyticsStorage)
|
||||
|
||||
expect(await screen.findByText('已使用 100.0%')).toBeInTheDocument()
|
||||
expect(screen.getByText('可用 0.00 B / 总容量 1.00 KB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the default values when the request fails', async () => {
|
||||
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
mocks.apiGet.mockRejectedValue(new Error('remote unavailable'))
|
||||
|
||||
await renderWithProviders(AnalyticsStorage)
|
||||
|
||||
await waitFor(() => expect(consoleLog).toHaveBeenCalledOnce())
|
||||
expect(screen.getByText('0.00 B')).toBeInTheDocument()
|
||||
expect(screen.getByText('已使用 0.0%')).toBeInTheDocument()
|
||||
expect(screen.getByText('可用 0.00 B / 总容量 0.00 B')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('loads once initially and refreshes once after KeepAlive reactivation', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
total_storage: 1024,
|
||||
used_storage: 512,
|
||||
},
|
||||
})
|
||||
|
||||
await renderWithProviders(KeepAliveHarness)
|
||||
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledOnce())
|
||||
await fireEvent.click(screen.getByRole('button', { name: '停用存储卡片' }))
|
||||
await fireEvent.click(screen.getByRole('button', { name: '启用存储卡片' }))
|
||||
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,13 @@
|
||||
import DashboardRecentImports from '@/views/dashboard/DashboardRecentImports.vue'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { screen } from '@testing-library/vue'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
formatDateDifference: vi.fn((date: string) => `relative:${date}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
@@ -13,22 +16,125 @@ vi.mock('@/api', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@core/utils/formatters', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('@core/utils/formatters')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
formatDateDifference: (...args: [string]) => mocks.formatDateDifference(...args),
|
||||
}
|
||||
})
|
||||
|
||||
const ImageStub = defineComponent({
|
||||
name: 'VImg',
|
||||
inheritAttrs: false,
|
||||
props: { alt: String, src: String },
|
||||
setup(props) {
|
||||
return () => h('img', { alt: props.alt, src: props.src })
|
||||
},
|
||||
})
|
||||
|
||||
const KeepAliveHarness = defineComponent({
|
||||
components: { DashboardRecentImports },
|
||||
setup() {
|
||||
const active = ref(true)
|
||||
|
||||
return { active }
|
||||
},
|
||||
template: `
|
||||
<button type="button" @click="active = false">停用最近入库</button>
|
||||
<button type="button" @click="active = true">启用最近入库</button>
|
||||
<KeepAlive><DashboardRecentImports v-if="active" /></KeepAlive>
|
||||
`,
|
||||
})
|
||||
|
||||
function renderRecentImports(component = DashboardRecentImports) {
|
||||
return renderWithProviders(component, {
|
||||
global: { stubs: { VImg: ImageStub } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('dashboard recent imports', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.formatDateDifference.mockClear()
|
||||
})
|
||||
|
||||
it('exposes the rendered list as a layout size source', async () => {
|
||||
it('loads five successful records once on an ordinary mount', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
list: [{ id: 1, title: '异步入库记录' }],
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = await renderWithProviders(DashboardRecentImports)
|
||||
const { container } = await renderRecentImports()
|
||||
|
||||
const renderedItem = await screen.findByText('异步入库记录')
|
||||
expect(container.querySelector('[data-layout-size-source]')).toContainElement(renderedItem)
|
||||
expect(mocks.apiGet).toHaveBeenCalledOnce()
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('history/transfer', {
|
||||
params: { page: 1, count: 5, status: true },
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['empty', []],
|
||||
['null', null],
|
||||
])('shows the empty state when the response list is %s', async (_label, list) => {
|
||||
mocks.apiGet.mockResolvedValue({ data: { list } })
|
||||
|
||||
const { container } = await renderRecentImports()
|
||||
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledOnce())
|
||||
expect(screen.getByText('暂无近期整理记录')).toBeInTheDocument()
|
||||
expect(container.querySelector('[data-layout-size-source]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses the image proxy for posters and the local fallback when no poster exists', async () => {
|
||||
const remotePoster = 'https://example.com/poster image.jpg?size=large&lang=zh'
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
list: [
|
||||
{ id: 1, image: remotePoster, title: '远端海报' },
|
||||
{ id: 2, title: '默认海报' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
await renderRecentImports()
|
||||
|
||||
expect(await screen.findByRole('img', { name: '远端海报' })).toHaveAttribute(
|
||||
'src',
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(remotePoster)}`,
|
||||
)
|
||||
expect(screen.getByRole('img', { name: '默认海报' })).toHaveAttribute('src', noImage)
|
||||
})
|
||||
|
||||
it('renders the year, relative date and available transfer metadata', async () => {
|
||||
const transferDate = '2026-08-19T07:30:00Z'
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
list: [
|
||||
{
|
||||
id: 1,
|
||||
date: transferDate,
|
||||
episodes: 'E03',
|
||||
seasons: 'S01',
|
||||
src_fileitem: { size: 1536 },
|
||||
title: '测试剧集',
|
||||
type: '电视剧',
|
||||
year: '2025',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
await renderRecentImports()
|
||||
|
||||
expect(await screen.findByText('测试剧集')).toHaveTextContent('测试剧集 (2025)')
|
||||
expect(screen.getByText('电视剧 · S01 · E03 · 1.50 KB')).toBeInTheDocument()
|
||||
expect(screen.getByText(`relative:${transferDate}`)).toBeInTheDocument()
|
||||
expect(mocks.formatDateDifference).toHaveBeenCalledWith(transferDate)
|
||||
})
|
||||
|
||||
it('shows normalized audio specs for recent music imports', async () => {
|
||||
@@ -48,8 +154,31 @@ describe('dashboard recent imports', () => {
|
||||
},
|
||||
})
|
||||
|
||||
await renderWithProviders(DashboardRecentImports)
|
||||
await renderRecentImports()
|
||||
|
||||
expect(await screen.findByText(/FLAC · 24-bit · 96 kHz · 2,304 kbps/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the empty state when the request fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mocks.apiGet.mockRejectedValue(new Error('remote unavailable'))
|
||||
|
||||
const { container } = await renderRecentImports()
|
||||
|
||||
await waitFor(() => expect(consoleError).toHaveBeenCalledOnce())
|
||||
expect(screen.getByText('暂无近期整理记录')).toBeInTheDocument()
|
||||
expect(container.querySelector('[data-layout-size-source]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('loads once initially and refreshes once after KeepAlive reactivation', async () => {
|
||||
mocks.apiGet.mockResolvedValue({ data: { list: [] } })
|
||||
|
||||
await renderRecentImports(KeepAliveHarness)
|
||||
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledOnce())
|
||||
await fireEvent.click(screen.getByRole('button', { name: '停用最近入库' }))
|
||||
await fireEvent.click(screen.getByRole('button', { name: '启用最近入库' }))
|
||||
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -329,6 +329,10 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/pages/subscribe.vue',
|
||||
'src/pages/plugin-app.vue',
|
||||
'src/views/dashboard/MediaRecommend.vue',
|
||||
'src/views/dashboard/AnalyticsNetwork.vue',
|
||||
'src/views/dashboard/AnalyticsScheduler.vue',
|
||||
'src/views/dashboard/AnalyticsStorage.vue',
|
||||
'src/views/dashboard/DashboardRecentImports.vue',
|
||||
'src/views/discover/MediaCardSlideView.vue',
|
||||
'src/views/subscribe/FullCalendarView.vue',
|
||||
'src/views/subscribe/SubscribeListView.vue',
|
||||
@@ -337,6 +341,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/composables/useMediaSubscribe.ts',
|
||||
'src/composables/useLlmProviderDirectory.ts',
|
||||
'src/composables/useOfflineStatus.ts',
|
||||
'src/composables/useScheduleProgress.ts',
|
||||
'src/composables/useServerConnectionProbe.ts',
|
||||
'src/composables/useTorrentFilter.ts',
|
||||
'src/layouts/default/components/OfflinePage.vue',
|
||||
@@ -434,6 +439,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/composables/useScheduleProgress.ts': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/composables/useServerConnectionProbe.ts': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
@@ -446,6 +457,30 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/views/dashboard/AnalyticsNetwork.vue': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/views/dashboard/AnalyticsScheduler.vue': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/views/dashboard/AnalyticsStorage.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/views/dashboard/DashboardRecentImports.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/components/cards/SubscribeCard.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
|
||||
Reference in New Issue
Block a user