fix(dashboard): avoid duplicate status card refreshes (#684)

This commit is contained in:
InfinityPacer
2026-08-19 12:22:56 +08:00
committed by GitHub
parent dabdf7c5f7
commit 0355ce5fdb
8 changed files with 1007 additions and 12 deletions
@@ -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('')
})
})
+3 -6
View File
@@ -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))
})
})
+35
View File
@@ -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,