test(subscribe): cover list management workflows (#535)

This commit is contained in:
InfinityPacer
2026-07-17 06:53:56 +08:00
committed by GitHub
parent 5390af50ac
commit c3c323b018
9 changed files with 1269 additions and 18 deletions

View File

@@ -234,6 +234,30 @@ describe('useMediaSubscribe entry flows', () => {
expect(mocks.doneProgress).toHaveBeenCalledOnce()
})
it('keeps a successful creation successful when default configuration loading fails', async () => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
const media = createSubscribeMovie({ title: '辅助查询失败电影', tmdb_id: 111 })
const created = vi.fn()
const configQueried = vi.fn()
server.use(
createSubscribeHandler({ data: { id: 511 }, success: true }, 200, created),
defaultSubscribeConfigHandler('电影', {}, 500, configQueried),
)
await renderSubscribeHarness({ media })
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
await waitFor(() => expect(created).toHaveBeenCalledOnce())
await waitFor(() => expect(configQueried).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce())
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
expect(mocks.cacheStatus).toHaveBeenCalledWith('status:all', true)
expect(mocks.toastSuccess).toHaveBeenCalledOnce()
expect(mocks.toastError).not.toHaveBeenCalled()
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
consoleLog.mockRestore()
})
it('opens the mode chooser for an existing movie and creates the selected mode', async () => {
const media = createSubscribeMovie({ title: '已入库电影', tmdb_id: 102 })
const created = vi.fn()

View File

@@ -54,10 +54,6 @@ interface SharedDialogEvents {
'update:modelValue': (value: boolean) => void
}
interface SharedDialogProps {
valueGetter: (item: { title: string }) => string
}
async function renderRecommend(options: { superUser?: boolean; discovery?: boolean } = {}) {
return renderWithProviders(RecommendPage, {
initialRoute: '/recommend',
@@ -196,7 +192,9 @@ describe('recommend page', () => {
const settingsButton = document.querySelector<HTMLButtonElement>('.compact-fab') as HTMLButtonElement
await user.click(settingsButton)
const dialogProps = mocks.openSharedDialog.mock.calls[0][1] as SharedDialogProps
const dialogProps = mocks.openSharedDialog.mock.calls[0][1] as {
valueGetter: (item: { title: string }) => string
}
const firstDialogEvents = mocks.openSharedDialog.mock.calls[0][2] as SharedDialogEvents
expect(dialogProps.valueGetter({ title: '流行趋势' })).toBe('流行趋势')

View File

@@ -0,0 +1,467 @@
import SubscribePage from '@/pages/subscribe.vue'
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import {
computed,
defineComponent,
h,
nextTick,
ref,
unref,
type ComputedRef,
type Ref,
} from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
appMode: false,
openSharedDialog: vi.fn(),
registerHeaderTab: vi.fn(),
useDynamicButton: vi.fn(),
}))
vi.mock('@/composables/useDynamicHeaderTab', () => ({
useDynamicHeaderTab: () => ({ registerHeaderTab: mocks.registerHeaderTab }),
}))
vi.mock('@/composables/useDynamicButton', () => ({
useDynamicButton: (options: unknown) => mocks.useDynamicButton(options),
}))
vi.mock('@/composables/usePWA', async () => {
const { computed } = await import('vue')
return {
usePWA: () => ({ appMode: computed(() => mocks.appMode) }),
}
})
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
interface SubscribeBatchState {
enabled: boolean
selectedCount: number
totalCount: number
allSelected: boolean
}
const SubscribeListViewStub = defineComponent({
name: 'SubscribeListView',
props: {
type: String,
subid: String,
keyword: String,
statusFilter: String,
sortMode: {
type: Boolean,
default: false,
},
sortBy: {
type: String,
default: '',
},
active: {
type: Boolean,
default: true,
},
},
emits: ['update:sortMode', 'update:sortBy', 'batch-state-change'],
setup(props, { emit, expose }) {
const lastCommand = ref('none')
const batchState = ref<SubscribeBatchState>({
enabled: false,
selectedCount: 0,
totalCount: 0,
allSelected: false,
})
const runCommand = (command: string) => {
lastCommand.value = command
}
const publishBatchState = (state: SubscribeBatchState) => {
batchState.value = state
emit('batch-state-change', state)
}
expose({
enterBatchMode: () => runCommand('enter-batch'),
exitBatchMode: () => runCommand('exit-batch'),
toggleSelectAll: () => runCommand('toggle-select-all'),
batchEnableSubscribes: () => runCommand('batch-enable'),
batchPauseSubscribes: () => runCommand('batch-pause'),
batchDeleteSubscribes: () => runCommand('batch-delete'),
openHistoryDialog: () => runCommand('open-history'),
})
return () =>
h('section', { 'aria-label': 'subscription list stub' }, [
h('button', { 'data-menu-activator': 'filter-btn', type: 'button' }, 'filter activator'),
h('output', { 'aria-label': 'list type' }, props.type ?? ''),
h('output', { 'aria-label': 'list subscription id' }, props.subid ?? ''),
h('output', { 'aria-label': 'list keyword' }, props.keyword ?? ''),
h('output', { 'aria-label': 'list status filter' }, props.statusFilter ?? ''),
h('output', { 'aria-label': 'list sort mode' }, String(props.sortMode)),
h('output', { 'aria-label': 'list sort by' }, props.sortBy ?? ''),
h('output', { 'aria-label': 'list active state' }, String(props.active)),
h('output', { 'aria-label': 'list batch state' }, JSON.stringify(batchState.value)),
h('output', { 'aria-label': 'last list command' }, lastCommand.value),
h(
'button',
{ type: 'button', onClick: () => emit('update:sortMode', true) },
'emit sort mode on',
),
h(
'button',
{ type: 'button', onClick: () => emit('update:sortMode', false) },
'emit sort mode off',
),
h('button', { type: 'button', onClick: () => emit('update:sortBy', 'date') }, 'emit date sort'),
h(
'button',
{
type: 'button',
onClick: () =>
publishBatchState({ enabled: true, selectedCount: 2, totalCount: 3, allSelected: false }),
},
'publish batch selection',
),
h(
'button',
{
type: 'button',
onClick: () =>
publishBatchState({ enabled: true, selectedCount: 3, totalCount: 3, allSelected: true }),
},
'publish all selected batch',
),
])
},
})
const SubscribePopularViewStub = defineComponent({
name: 'SubscribePopularView',
props: { type: String },
setup(props) {
return () => h('section', { 'aria-label': 'popular subscription stub' }, props.type ?? '')
},
})
const SubscribeShareViewStub = defineComponent({
name: 'SubscribeShareView',
props: { keyword: String },
setup(props) {
return () =>
h('section', { 'aria-label': 'shared subscription stub' }, [
h('button', { 'data-menu-activator': 'share-filter-btn', type: 'button' }, 'share filter activator'),
h('output', { 'aria-label': 'share keyword' }, props.keyword ?? ''),
])
},
})
type MaybeRef<T> = T | Ref<T> | ComputedRef<T>
interface HeaderButtonConfig {
icon: string
dataAttr?: string
action?: () => void
color?: MaybeRef<string>
show?: MaybeRef<boolean>
}
interface HeaderTabConfig {
items: MaybeRef<Array<{ title: string; tab: string }>>
modelValue: Ref<string>
appendButtons: HeaderButtonConfig[]
}
interface DynamicButtonConfig {
icon: MaybeRef<string>
menuItems?: MaybeRef<DynamicButtonMenuItem[] | undefined>
onClick?: () => void
show?: MaybeRef<boolean>
}
interface RenderSubscribeOptions {
appMode?: boolean
initialRoute?: string
subType?: '电影' | '电视剧'
subscribePermission?: boolean
superUser?: boolean
}
async function renderSubscribe(options: RenderSubscribeOptions = {}) {
const subType = options.subType ?? '电影'
mocks.appMode = options.appMode ?? false
return renderWithProviders(SubscribePage, {
initialRoute: options.initialRoute ?? `/subscribe/${subType === '电影' ? 'movie' : 'tv'}`,
initialRouteMeta: { subType },
initialState: {
user: {
permissions: {
...DEFAULT_PERMISSIONS,
subscribe: options.subscribePermission ?? true,
},
superUser: options.superUser ?? false,
},
},
global: {
stubs: {
SubscribeListView: SubscribeListViewStub,
SubscribePopularView: SubscribePopularViewStub,
SubscribeShareView: SubscribeShareViewStub,
},
},
})
}
function getHeaderConfig() {
return mocks.registerHeaderTab.mock.calls.at(-1)?.[0] as HeaderTabConfig
}
function getDynamicButtonConfig() {
return mocks.useDynamicButton.mock.calls.at(-1)?.[0] as DynamicButtonConfig
}
function getHeaderButton(predicate: (button: HeaderButtonConfig) => boolean) {
const button = getHeaderConfig().appendButtons.find(predicate)
if (!button) throw new Error('Expected dynamic header button was not registered')
return button
}
function getListOutput(label: string) {
return screen.getByLabelText(label)
}
describe('subscribe page', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.appMode = false
})
it('uses movie route meta and query values to register the movie page contract', async () => {
const { router } = await renderSubscribe({ initialRoute: '/subscribe/movie?id=42' })
await waitFor(() => expect(getListOutput('list active state')).toHaveTextContent('true'))
const header = getHeaderConfig()
expect(router.currentRoute.value.meta.subType).toBe('电影')
expect(unref(header.items).map(item => item.tab)).toEqual(['mysub', 'popular'])
expect(header.modelValue.value).toBe('mysub')
expect(getListOutput('list type')).toHaveTextContent('电影')
expect(getListOutput('list subscription id')).toHaveTextContent('42')
})
it('uses TV route meta and tab query to expose the share page contract', async () => {
const { router } = await renderSubscribe({
initialRoute: '/subscribe/tv?tab=share&id=73',
subType: '电视剧',
})
const header = getHeaderConfig()
expect(router.currentRoute.value.meta.subType).toBe('电视剧')
expect(unref(header.items).map(item => item.tab)).toEqual(['mysub', 'popular', 'share'])
expect(header.modelValue.value).toBe('share')
expect(screen.getByLabelText('share keyword')).toHaveTextContent('')
})
it.each([
['movie value', '电影' as const, 'last_update', 'last_update'],
['TV-only value', '电视剧' as const, 'lack_episode', 'lack_episode'],
['invalid value', '电视剧' as const, 'unexpected', ''],
['TV-only value on movies', '电影' as const, 'lack_episode', ''],
])('normalizes stored sorting for %s', async (_case, subType, storedSort, expectedSort) => {
localStorage.setItem(`MPSubscribeSortBy:${subType}`, storedSort)
await renderSubscribe({ subType })
expect(getListOutput('list sort by')).toHaveTextContent(expectedSort)
})
it('keeps page state usable when sort storage reads or writes fail', async () => {
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('storage read failed')
})
await renderSubscribe()
expect(getListOutput('list sort by')).toHaveTextContent('')
expect(consoleWarn).toHaveBeenCalledWith('读取订阅排序方式失败:', expect.any(Error))
getItem.mockRestore()
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('storage write failed')
})
await fireEvent.click(screen.getByRole('button', { name: 'emit date sort' }))
await waitFor(() => expect(getListOutput('list sort by')).toHaveTextContent('date'))
expect(consoleWarn).toHaveBeenCalledWith('保存订阅排序方式失败:', expect.any(Error))
})
it('coordinates filter and sort state through header actions and list emits', async () => {
await renderSubscribe({ subType: '电视剧' })
const filterButton = getHeaderButton(button => button.dataAttr === 'filter-btn')
const sortButton = getHeaderButton(button => button.icon === 'mdi-sort-variant')
filterButton.action?.()
await nextTick()
const nameInput = await screen.findByPlaceholderText('名称')
await fireEvent.update(nameInput, 'Matrix')
expect(getListOutput('list keyword')).toHaveTextContent('Matrix')
await fireEvent.click(screen.getByText('暂停'))
expect(getListOutput('list status filter')).toHaveTextContent('paused')
sortButton.action?.()
await nextTick()
expect(getListOutput('list sort mode')).toHaveTextContent('true')
expect(getListOutput('list sort by')).toHaveTextContent('custom')
await fireEvent.click(screen.getByRole('button', { name: 'emit sort mode off' }))
await fireEvent.click(screen.getByRole('button', { name: 'emit date sort' }))
expect(getListOutput('list sort mode')).toHaveTextContent('false')
expect(getListOutput('list sort by')).toHaveTextContent('date')
})
it('exits batch management before entering drag sorting', async () => {
await renderSubscribe({ appMode: true })
const sortButton = getHeaderButton(button => button.icon === 'mdi-sort-variant')
const batchButton = getHeaderButton(button => button.icon === 'mdi-checkbox-multiple-marked-outline')
await fireEvent.click(screen.getByRole('button', { name: 'publish batch selection' }))
expect(unref(getDynamicButtonConfig().show)).toBe(true)
sortButton.action?.()
await nextTick()
expect(getListOutput('last list command')).toHaveTextContent('exit-batch')
expect(getListOutput('list sort mode')).toHaveTextContent('true')
expect(getListOutput('list sort by')).toHaveTextContent('custom')
expect(unref(batchButton.color)).toBe('gray')
expect(unref(getDynamicButtonConfig().show)).toBe(false)
})
it('delegates PWA batch actions to the list public API', async () => {
await renderSubscribe({ appMode: true })
const batchButton = getHeaderButton(button => button.icon === 'mdi-checkbox-multiple-marked-outline')
batchButton.action?.()
await nextTick()
expect(getListOutput('last list command')).toHaveTextContent('enter-batch')
await fireEvent.click(screen.getByRole('button', { name: 'publish batch selection' }))
const dynamicButton = getDynamicButtonConfig()
const menuItems = unref(dynamicButton.menuItems) ?? []
expect(unref(dynamicButton.show)).toBe(true)
expect(unref(dynamicButton.icon)).toBe('mdi-checkbox-multiple-marked-outline')
expect(menuItems.find(item => item.titleKey === 'subscribe.batchSelectAll')?.disabled).toBe(false)
for (const [titleKey, command] of [
['subscribe.batchSelectAll', 'toggle-select-all'],
['subscribe.batchEnable', 'batch-enable'],
['subscribe.batchPause', 'batch-pause'],
['subscribe.batchDelete', 'batch-delete'],
] as const) {
menuItems.find(item => item.titleKey === titleKey)?.action()
await nextTick()
expect(getListOutput('last list command')).toHaveTextContent(command)
}
dynamicButton.onClick?.()
await nextTick()
expect(getListOutput('last list command')).toHaveTextContent('exit-batch')
})
it('exits batch mode when the header leaves the personal subscription tab', async () => {
await renderSubscribe({ appMode: true, subType: '电视剧' })
await fireEvent.click(screen.getByRole('button', { name: 'publish batch selection' }))
getHeaderConfig().modelValue.value = 'popular'
await nextTick()
expect(getListOutput('last list command')).toHaveTextContent('exit-batch')
expect(getListOutput('list active state')).toHaveTextContent('false')
expect(unref(getDynamicButtonConfig().icon)).toBe('mdi-clipboard-edit-outline')
})
it('exposes administrator history and default-rule actions on desktop and PWA', async () => {
const { unmount } = await renderSubscribe({ superUser: true })
await waitFor(() => expect(document.querySelectorAll('.compact-fab button')).toHaveLength(2))
const [historyButton, defaultRuleButton] = document.querySelectorAll<HTMLButtonElement>('.compact-fab button')
await fireEvent.click(historyButton)
expect(getListOutput('last list command')).toHaveTextContent('open-history')
await fireEvent.click(defaultRuleButton)
expect(mocks.openSharedDialog).toHaveBeenCalledWith(
expect.any(Object),
{ default: true, type: '电影' },
{},
{ closeOn: ['close', 'save'] },
)
unmount()
await renderSubscribe({ appMode: true, superUser: true })
const dynamicButton = getDynamicButtonConfig()
expect(unref(dynamicButton.show)).toBe(true)
expect(unref(dynamicButton.icon)).toBe('mdi-history')
expect(unref(dynamicButton.menuItems)?.map(item => item.titleKey)).toEqual([
'dialog.subscribeHistory.title',
'dialog.subscribeEdit.titleDefault',
])
})
it.each([
[true, true],
[false, false],
])('gates the PWA share statistics action by subscribe permission=%s', async (permission, visible) => {
await renderSubscribe({
appMode: true,
initialRoute: '/subscribe/tv?tab=share',
subType: '电视剧',
subscribePermission: permission,
})
const dynamicButton = getDynamicButtonConfig()
expect(unref(dynamicButton.show)).toBe(visible)
if (visible) {
expect(unref(dynamicButton.icon)).toBe('mdi-chart-line')
dynamicButton.onClick?.()
expect(mocks.openSharedDialog).toHaveBeenCalledWith(
expect.any(Object),
{},
{},
{ closeOn: ['close'] },
)
}
})
it('debounces and trims share search, then cancels pending work on unmount', async () => {
const { unmount } = await renderSubscribe({
initialRoute: '/subscribe/tv?tab=share',
subType: '电视剧',
})
getHeaderButton(button => button.dataAttr === 'share-filter-btn').action?.()
await nextTick()
const keywordInput = await screen.findByPlaceholderText('关键词')
vi.useFakeTimers()
await fireEvent.update(keywordInput, ' science fiction ')
expect(getListOutput('share keyword')).toHaveTextContent('')
vi.advanceTimersByTime(299)
await nextTick()
expect(getListOutput('share keyword')).toHaveTextContent('')
vi.advanceTimersByTime(1)
await nextTick()
expect(getListOutput('share keyword')).toHaveTextContent('science fiction')
await fireEvent.update(keywordInput, 'pending')
expect(vi.getTimerCount()).toBeGreaterThan(0)
unmount()
expect(vi.getTimerCount()).toBe(0)
})
})

View File

@@ -282,12 +282,18 @@ function batchDeleteSelectedSubscribes() {
subscribeListViewRef.value?.batchDeleteSubscribes()
}
// 切换订阅拖拽排序模式,进入时固定使用自定义排序。
// 批量选择与拖拽排序互斥,进入排序模式时清空批量选择
function toggleSubscribeSortMode() {
if (!subscribeSortMode.value) {
const nextSortMode = !subscribeSortMode.value
if (nextSortMode) {
if (subscribeBatchState.value.enabled) {
exitSubscribeBatchMode()
}
subscribeSortBy.value = 'custom'
}
subscribeSortMode.value = !subscribeSortMode.value
subscribeSortMode.value = nextSortMode
}
const shareKeywordUpdater = debounce((keyword: string) => {

View File

@@ -76,6 +76,9 @@ let isRefreshed = ref(false)
// 刷新状态
const loading = ref(false)
// 最近一次列表请求是否失败,用于保留旧数据时持续展示错误状态。
const loadError = ref(false)
// 数据列表
const dataList = ref<Subscribe[]>([])
@@ -93,7 +96,7 @@ const normalizedKeyword = computed(() => props.keyword?.trim().toLowerCase() ||
const selectedSubscribesSet = computed(() => new Set(selectedSubscribes.value))
const hasCustomOrder = computed(() => orderConfig.value.length > 0)
const isAllSubscribesSelected = computed(
() => displayList.value.length > 0 && selectedSubscribes.value.length === displayList.value.length,
() => displayList.value.length > 0 && displayList.value.every(item => selectedSubscribesSet.value.has(item.id)),
)
// 归一化订阅排序方式,电影订阅不使用缺失集数排序。
@@ -253,6 +256,8 @@ watch(
sortSubscribeList(nextDisplayList)
displayList.value = nextDisplayList
const visibleIds = new Set(nextDisplayList.map(item => item.id))
selectedSubscribes.value = selectedSubscribes.value.filter(id => visibleIds.has(id))
},
{ immediate: true },
)
@@ -290,31 +295,44 @@ async function loadSubscribeOrderConfig() {
// 保存顺序设置
async function saveSubscribeOrder() {
// 顺序配置
const confirmedOrder = orderConfig.value.map(item => ({ ...item }))
const orderObj = displayList.value.map(item => ({ id: item.id }))
orderConfig.value = orderObj
emit('update:sortBy', 'custom')
// 保存到服务端
try {
await api.post(`/user/config/${orderRequestKey.value}`, orderObj)
} catch (error) {
console.error(error)
orderConfig.value = confirmedOrder
const restoredDisplayList = [...displayList.value]
sortSubscribeList(restoredDisplayList)
displayList.value = restoredDisplayList
$toast.error(t('subscribe.requestFailed'))
}
}
// 获取订阅列表数据
async function fetchData(context: KeepAliveRefreshContext = {}) {
const showLoading = !context.silent || !isRefreshed.value
const isInitialLoad = !isRefreshed.value
try {
if (showLoading) {
loading.value = true
}
dataList.value = await api.get('subscribe/')
loadError.value = false
isRefreshed.value = true
} catch (error) {
console.error(error)
loadError.value = true
if (isInitialLoad) {
isRefreshed.value = true
}
if (!context.silent || isInitialLoad) {
$toast.error(t('subscribe.requestFailed'))
}
} finally {
if (showLoading) {
loading.value = false
@@ -443,10 +461,14 @@ async function batchEnableSubscribes() {
try {
loading.value = true
const promises = selectedSubscribes.value.map(id => api.put(`subscribe/status/${id}?state=R`))
const promises = selectedSubscribes.value.map(
id => api.put(`subscribe/status/${id}?state=R`) as unknown as Promise<{ success: boolean }>,
)
const results = await Promise.allSettled(promises)
const successCount = results.filter(result => result.status === 'fulfilled').length
const successCount = results.filter(
result => result.status === 'fulfilled' && result.value?.success === true,
).length
const failedCount = results.length - successCount
if (successCount > 0) {
@@ -482,10 +504,14 @@ async function batchPauseSubscribes() {
try {
loading.value = true
const promises = selectedSubscribes.value.map(id => api.put(`subscribe/status/${id}?state=S`))
const promises = selectedSubscribes.value.map(
id => api.put(`subscribe/status/${id}?state=S`) as unknown as Promise<{ success: boolean }>,
)
const results = await Promise.allSettled(promises)
const successCount = results.filter(result => result.status === 'fulfilled').length
const successCount = results.filter(
result => result.status === 'fulfilled' && result.value?.success === true,
).length
const failedCount = results.length - successCount
if (successCount > 0) {
@@ -554,6 +580,10 @@ defineExpose({
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VAlert v-if="loadError" type="error" variant="tonal" class="mb-4 mx-2">
{{ t('subscribe.requestFailed') }}
</VAlert>
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4 mx-2 py-0 app-surface-static">
<div class="d-flex flex-wrap align-center justify-space-between gap-2 py-5">
<span>{{ t('common.sortModeHint') }}</span>
@@ -608,7 +638,7 @@ defineExpose({
</template>
</ProgressiveCardGrid>
<NoDataFound
v-if="displayList.length === 0 && isRefreshed"
v-if="displayList.length === 0 && isRefreshed && !loadError"
error-code="404"
:error-title="errorTitle"
:error-description="errorDescription"

View File

@@ -0,0 +1,657 @@
import type { Subscribe } from '@/api/types'
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { createSubscribe } from '@tests/support/factories/subscribe'
import {
deleteSubscribeByIdHandler,
saveSubscribeOrderConfigHandler,
subscribeApiUrls,
subscribeListHandler,
subscribeOrderConfigHandler,
updateSubscribeStatusHandler,
type SubscribeMediaType,
} from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h, nextTick, ref, watch, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
confirm: vi.fn(),
openSharedDialog: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
toastWarning: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({
error: mocks.toastError,
success: mocks.toastSuccess,
warning: mocks.toastWarning,
}),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => mocks.confirm,
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
const SubscribeCardStub = defineComponent({
name: 'SubscribeCard',
props: {
batchMode: Boolean,
media: { type: Object as PropType<Subscribe>, required: true },
selected: Boolean,
sortable: Boolean,
},
emits: ['remove', 'save', 'select'],
setup(props, { emit }) {
return () =>
h(
'article',
{
'data-batch': String(props.batchMode),
'data-page-open': String(Boolean(props.media.page_open)),
'data-selected': String(props.selected),
'data-sortable': String(props.sortable),
'data-testid': `subscribe-card-${props.media.id}`,
},
[
h('span', props.media.name),
h('button', { 'aria-label': `select-${props.media.id}`, onClick: () => emit('select'), type: 'button' }, 'select'),
h('button', { 'aria-label': `save-${props.media.id}`, onClick: () => emit('save'), type: 'button' }, 'save'),
h('button', { 'aria-label': `remove-${props.media.id}`, onClick: () => emit('remove'), type: 'button' }, 'remove'),
],
)
},
})
const ProgressiveCardGridStub = defineComponent({
name: 'ProgressiveCardGrid',
props: {
items: { type: Array as PropType<Subscribe[]>, required: true },
scrollToIndex: Number,
},
setup(props, { slots }) {
return () =>
h(
'section',
{
'data-scroll-to-index': props.scrollToIndex ?? '',
'data-testid': 'progressive-grid',
},
props.items.flatMap(item => slots.default?.({ item }) ?? []),
)
},
})
const DraggableStub = defineComponent({
name: 'Draggable',
props: {
modelValue: { type: Array as PropType<Subscribe[]>, required: true },
},
emits: ['end', 'update:modelValue'],
setup(props, { emit, slots }) {
async function reverseOrder() {
emit('update:modelValue', [...props.modelValue].reverse())
await nextTick()
emit('end')
}
return () =>
h('section', { 'data-testid': 'draggable-list' }, [
...props.modelValue.flatMap(element => slots.item?.({ element }) ?? []),
h('button', { onClick: reverseOrder, type: 'button' }, 'reverse-order'),
])
},
})
const LoadingBannerStub = defineComponent({
name: 'LoadingBanner',
template: '<div role="status" data-testid="loading-banner">loading</div>',
})
const NoDataFoundStub = defineComponent({
name: 'NoDataFound',
props: {
errorDescription: String,
errorTitle: String,
},
template: '<section data-testid="no-data">{{ errorTitle }} {{ errorDescription }}</section>',
})
interface BatchState {
allSelected: boolean
enabled: boolean
selectedCount: number
totalCount: number
}
interface ListActions {
batchDeleteSubscribes: () => Promise<void>
batchEnableSubscribes: () => Promise<void>
batchPauseSubscribes: () => Promise<void>
enterBatchMode: () => void
exitBatchMode: () => void
openHistoryDialog: () => void
toggleBatchMode: () => void
toggleSelectAll: () => void
}
const SubscribeListHost = defineComponent({
name: 'SubscribeListHost',
components: { SubscribeListView },
props: {
active: { type: Boolean, default: true },
keyword: { type: String, default: '' },
sortBy: { type: String, default: '' },
sortMode: { type: Boolean, default: false },
statusFilter: { type: String, default: 'all' },
subid: { type: String, default: '' },
type: { type: String as PropType<SubscribeMediaType>, default: '电影' },
},
setup(props) {
const list = ref<ListActions | null>(null)
const currentSortBy = ref(props.sortBy)
const currentSortMode = ref(props.sortMode)
const batchState = ref<BatchState>({ allSelected: false, enabled: false, selectedCount: 0, totalCount: 0 })
watch(
() => props.sortBy,
value => {
currentSortBy.value = value
},
)
watch(
() => props.sortMode,
value => {
currentSortMode.value = value
},
)
function call(action: keyof ListActions) {
return list.value?.[action]()
}
return { batchState, call, currentSortBy, currentSortMode, list }
},
template: `
<SubscribeListView
ref="list"
:active="active"
:keyword="keyword"
:sort-by="currentSortBy"
:sort-mode="currentSortMode"
:status-filter="statusFilter"
:subid="subid"
:type="type"
@batch-state-change="batchState = $event"
@update:sort-by="currentSortBy = $event"
@update:sort-mode="currentSortMode = $event"
/>
<button type="button" @click="call('enterBatchMode')">host-enter-batch</button>
<button type="button" @click="call('exitBatchMode')">host-exit-batch</button>
<button type="button" @click="call('toggleBatchMode')">host-toggle-batch</button>
<button type="button" @click="call('toggleSelectAll')">host-toggle-select-all</button>
<button type="button" @click="call('batchEnableSubscribes')">host-batch-enable</button>
<button type="button" @click="call('batchPauseSubscribes')">host-batch-pause</button>
<button type="button" @click="call('batchDeleteSubscribes')">host-batch-delete</button>
<button type="button" @click="call('openHistoryDialog')">host-open-history</button>
<output data-testid="batch-state">{{ JSON.stringify(batchState) }}</output>
<output data-testid="sort-by-state">{{ currentSortBy }}</output>
<output data-testid="sort-mode-state">{{ String(currentSortMode) }}</output>
`,
})
interface RenderListOptions {
active?: boolean
keyword?: string
listResponse?: Subscribe[]
listStatus?: number
onListRequest?: (url: URL) => void
onOrderRequest?: (url: URL) => void
orderStatus?: number
orderValue?: Parameters<typeof subscribeOrderConfigHandler>[1]
sortBy?: string
sortMode?: boolean
statusFilter?: string
subid?: string
superUser?: boolean
type?: SubscribeMediaType
userName?: string
}
async function renderList(options: RenderListOptions = {}) {
const type = options.type ?? '电影'
server.use(
subscribeOrderConfigHandler(
type,
options.orderValue,
options.orderStatus ?? 200,
options.onOrderRequest,
),
subscribeListHandler(options.listResponse ?? [], options.listStatus ?? 200, options.onListRequest),
)
return renderWithProviders(SubscribeListHost, {
props: {
active: options.active ?? true,
keyword: options.keyword ?? '',
sortBy: options.sortBy ?? '',
sortMode: options.sortMode ?? false,
statusFilter: options.statusFilter ?? 'all',
subid: options.subid ?? '',
type,
},
initialState: {
user: {
superUser: options.superUser ?? false,
userName: options.userName ?? 'tester',
},
},
global: {
stubs: {
Draggable: DraggableStub,
LoadingBanner: LoadingBannerStub,
NoDataFound: NoDataFoundStub,
ProgressiveCardGrid: ProgressiveCardGridStub,
SubscribeCard: SubscribeCardStub,
draggable: DraggableStub,
},
},
})
}
function movie(id: number, name: string, overrides: Partial<Subscribe> = {}) {
return createSubscribe({ id, name, type: '电影', username: 'tester', ...overrides })
}
function tv(id: number, name: string, overrides: Partial<Subscribe> = {}) {
return createSubscribe({ id, name, type: '电视剧', username: 'tester', ...overrides })
}
function card(id: number) {
return screen.getByTestId(`subscribe-card-${id}`)
}
function displayedNames() {
return screen.queryAllByTestId(/^subscribe-card-/).map(element => element.querySelector('span')?.textContent)
}
function batchState(): BatchState {
return JSON.parse(screen.getByTestId('batch-state').textContent || '{}') as BatchState
}
beforeEach(() => {
Object.values(mocks).forEach(mock => mock.mockReset())
mocks.confirm.mockResolvedValue(true)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
})
describe('SubscribeListView loading and filtering', () => {
it('loads exact endpoints and restricts a normal user by owner and media type', async () => {
const listRequested = vi.fn()
const orderRequested = vi.fn()
await renderList({
listResponse: [movie(1, 'Own movie'), movie(2, 'Other movie', { username: 'other' }), tv(3, 'Own TV')],
onListRequest: listRequested,
onOrderRequest: orderRequested,
})
expect(await screen.findByText('Own movie')).toBeInTheDocument()
expect(screen.queryByText('Other movie')).not.toBeInTheDocument()
expect(screen.queryByText('Own TV')).not.toBeInTheDocument()
expect(orderRequested.mock.calls[0][0].href).toBe(subscribeApiUrls.orderConfig('电影'))
expect(listRequested.mock.calls[0][0].href).toBe(subscribeApiUrls.list)
expect(screen.getByTestId('sort-by-state')).toHaveTextContent('date')
})
it('lets a superuser see subscriptions from every owner while retaining type defense', async () => {
await renderList({
listResponse: [movie(1, 'Own movie'), movie(2, 'Other movie', { username: 'other' }), tv(3, 'Other TV')],
superUser: true,
})
expect(await screen.findByText('Own movie')).toBeInTheDocument()
expect(screen.getByText('Other movie')).toBeInTheDocument()
expect(screen.queryByText('Other TV')).not.toBeInTheDocument()
})
it('normalizes keyword filtering', async () => {
await renderList({ keyword: ' ALPHA ', listResponse: [movie(1, 'Alpha One'), movie(2, 'Beta Two')] })
expect(await screen.findByText('Alpha One')).toBeInTheDocument()
expect(screen.queryByText('Beta Two')).not.toBeInTheDocument()
})
it.each([
['best_version', 'Best'],
['pending', 'Pending'],
['paused', 'Paused'],
['completed', 'Completed'],
['subscribing', 'Subscribing'],
['not_started', 'Not started'],
])('derives the %s status defensively', async (statusFilter, expectedName) => {
const subscriptions = [
tv(11, 'Best', { best_version: 1 }),
tv(12, 'Pending', { state: 'P' }),
tv(13, 'Paused', { state: 'S' }),
tv(14, 'Completed', { completed_episode: 10, lack_episode: 0, total_episode: 10 }),
tv(15, 'Subscribing', { completed_episode: 6, lack_episode: 4, total_episode: 10 }),
tv(16, 'Not started', { completed_episode: 0, lack_episode: 10, total_episode: 10 }),
]
await renderList({ listResponse: subscriptions, statusFilter, type: '电视剧' })
expect(await screen.findByText(expectedName)).toBeInTheDocument()
expect(displayedNames()).toEqual([expectedName])
})
it('shows the empty state after a successful empty list response', async () => {
await renderList({ listResponse: [] })
expect(await screen.findByTestId('no-data')).toBeInTheDocument()
expect(screen.queryByTestId('loading-banner')).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('finishes the initial loading state and shows a visible error when the list request fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const listRequested = vi.fn()
await renderList({ listResponse: [], listStatus: 500, onListRequest: listRequested })
await waitFor(() => expect(listRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.queryByTestId('loading-banner')).not.toBeInTheDocument())
expect(screen.getByRole('alert')).toHaveTextContent('请求失败,请稍后重试')
expect(screen.queryByTestId('no-data')).not.toBeInTheDocument()
expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试')
})
it('keeps old data through a silent refresh failure and clears the error after recovery', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const failedRequest = vi.fn()
const recoveredRequest = vi.fn()
const { rerender } = await renderList({ listResponse: [movie(1, 'Cached movie')] })
await screen.findByText('Cached movie')
await rerender({ active: false })
server.use(subscribeListHandler([], 500, failedRequest))
await rerender({ active: true })
await waitFor(() => expect(failedRequest).toHaveBeenCalledOnce())
expect(screen.getByText('Cached movie')).toBeInTheDocument()
expect(screen.getByRole('alert')).toHaveTextContent('请求失败,请稍后重试')
expect(screen.queryByTestId('loading-banner')).not.toBeInTheDocument()
await rerender({ active: false })
server.use(subscribeListHandler([movie(2, 'Recovered movie')], 200, recoveredRequest))
await rerender({ active: true })
await waitFor(() => expect(recoveredRequest).toHaveBeenCalledOnce())
expect(await screen.findByText('Recovered movie')).toBeInTheDocument()
expect(screen.queryByText('Cached movie')).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
})
describe('SubscribeListView sorting and refresh boundaries', () => {
it('applies custom order first and appends unconfigured subscriptions by date', async () => {
await renderList({
listResponse: [
movie(1, 'Old unconfigured', { date: '2024-01-01' }),
movie(2, 'Configured', { date: '2023-01-01' }),
movie(3, 'New unconfigured', { date: '2025-01-01' }),
],
orderValue: [{ id: 2 }],
sortBy: 'custom',
})
await screen.findByText('Configured')
expect(displayedNames()).toEqual(['Configured', 'New unconfigured', 'Old unconfigured'])
})
it.each([
[
'date',
[movie(1, 'Invalid date', { date: 'not-a-date' }), movie(2, 'Newest', { date: '2025-02-01' })],
['Newest', 'Invalid date'],
],
[
'last_update',
[movie(1, 'Invalid update', { last_update: 'bad' }), movie(2, 'Latest update', { last_update: '2025-02-01' })],
['Latest update', 'Invalid update'],
],
[
'lack_episode',
[
tv(1, 'Few missing', { date: '2025-03-01', lack_episode: 1 }),
tv(2, 'Many missing', { date: '2024-01-01', lack_episode: 8 }),
tv(3, 'Few newer', { date: '2025-04-01', lack_episode: 1 }),
],
['Many missing', 'Few newer', 'Few missing'],
],
])('sorts by %s and treats invalid dates as zero', async (sortBy, subscriptions, expected) => {
await renderList({
listResponse: subscriptions,
sortBy,
type: sortBy === 'lack_episode' ? '电视剧' : '电影',
})
await screen.findByText(expected[0])
expect(displayedNames()).toEqual(expected)
})
it('marks and scrolls to the initial subscription id', async () => {
await renderList({ listResponse: [movie(1, 'First'), movie(2, 'Target')], subid: '2' })
await screen.findByText('Target')
expect(card(2)).toHaveAttribute('data-page-open', 'true')
expect(screen.getByTestId('progressive-grid')).toHaveAttribute('data-scroll-to-index', '1')
})
it('refreshes from card save/remove and the history save boundary', async () => {
const listRequested = vi.fn()
await renderList({ listResponse: [movie(1, 'Refresh target')], onListRequest: listRequested })
await screen.findByText('Refresh target')
expect(listRequested).toHaveBeenCalledTimes(1)
await fireEvent.click(screen.getByRole('button', { name: 'save-1' }))
await waitFor(() => expect(listRequested).toHaveBeenCalledTimes(2))
await fireEvent.click(screen.getByRole('button', { name: 'remove-1' }))
await waitFor(() => expect(listRequested).toHaveBeenCalledTimes(3))
await fireEvent.click(screen.getByRole('button', { name: 'host-open-history' }))
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
expect(mocks.openSharedDialog.mock.calls[0][1]).toEqual({ type: '电影' })
const events = mocks.openSharedDialog.mock.calls[0][2] as { save: () => void }
events.save()
await waitFor(() => expect(listRequested).toHaveBeenCalledTimes(4))
})
it('commits a custom order only after a successful response', async () => {
const saved = vi.fn()
server.use(saveSubscribeOrderConfigHandler('电影', { success: true }, 200, saved))
await renderList({
listResponse: [movie(1, 'First'), movie(2, 'Second')],
orderValue: [{ id: 1 }, { id: 2 }],
sortBy: 'custom',
sortMode: true,
})
await screen.findByText('First')
await fireEvent.click(screen.getByRole('button', { name: 'reverse-order' }))
await waitFor(() => expect(saved).toHaveBeenCalledOnce())
expect(saved.mock.calls[0][0]).toEqual([{ id: 2 }, { id: 1 }])
expect(saved.mock.calls[0][1].href).toBe(subscribeApiUrls.orderConfig('电影'))
expect(displayedNames()).toEqual(['Second', 'First'])
expect(screen.getByTestId('sort-mode-state')).toHaveTextContent('true')
})
it('rolls back the confirmed order and remains sortable after a request failure', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
server.use(saveSubscribeOrderConfigHandler('电影', { message: 'server error', success: false }, 500))
await renderList({
listResponse: [movie(1, 'First'), movie(2, 'Second')],
orderValue: [{ id: 1 }, { id: 2 }],
sortBy: 'custom',
sortMode: true,
})
await screen.findByText('First')
await fireEvent.click(screen.getByRole('button', { name: 'reverse-order' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试'))
expect(displayedNames()).toEqual(['First', 'Second'])
expect(screen.getByTestId('sort-mode-state')).toHaveTextContent('true')
})
})
describe('SubscribeListView batch operations', () => {
it('exits drag sorting when batch mode makes the list unsortable', async () => {
await renderList({
listResponse: [movie(1, 'Alpha'), movie(2, 'Beta')],
orderValue: [{ id: 1 }, { id: 2 }],
sortBy: 'custom',
sortMode: true,
})
await screen.findByText('Alpha')
expect(screen.getByTestId('sort-mode-state')).toHaveTextContent('true')
await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' }))
await waitFor(() => expect(screen.getByTestId('sort-mode-state')).toHaveTextContent('false'))
expect(batchState()).toMatchObject({ enabled: true, selectedCount: 0, totalCount: 2 })
})
it('intersects selection with the visible list and never treats equal lengths as equal ids', async () => {
const statusRequested = vi.fn()
server.use(updateSubscribeStatusHandler(1, { success: true }, 200, statusRequested))
const { rerender } = await renderList({ listResponse: [movie(1, 'Alpha'), movie(2, 'Beta')] })
await screen.findByText('Alpha')
await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' }))
await fireEvent.click(screen.getByRole('button', { name: 'select-1' }))
expect(batchState()).toMatchObject({ allSelected: false, enabled: true, selectedCount: 1, totalCount: 2 })
await rerender({ keyword: 'Beta' })
await waitFor(() => expect(displayedNames()).toEqual(['Beta']))
expect(card(2)).toHaveAttribute('data-selected', 'false')
expect(batchState()).toMatchObject({ allSelected: false, enabled: true, selectedCount: 0, totalCount: 1 })
await fireEvent.click(screen.getByRole('button', { name: 'host-batch-enable' }))
expect(mocks.toastWarning).toHaveBeenCalledWith('请先选择要操作的订阅')
expect(statusRequested).not.toHaveBeenCalled()
})
it('selects and deselects the exact visible id set', async () => {
await renderList({ listResponse: [movie(1, 'Alpha'), movie(2, 'Beta')] })
await screen.findByText('Alpha')
await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' }))
await fireEvent.click(screen.getByRole('button', { name: 'host-toggle-select-all' }))
expect(card(1)).toHaveAttribute('data-selected', 'true')
expect(card(2)).toHaveAttribute('data-selected', 'true')
expect(batchState()).toMatchObject({ allSelected: true, selectedCount: 2, totalCount: 2 })
await fireEvent.click(screen.getByRole('button', { name: 'host-toggle-select-all' }))
expect(card(1)).toHaveAttribute('data-selected', 'false')
expect(card(2)).toHaveAttribute('data-selected', 'false')
expect(batchState()).toMatchObject({ allSelected: false, selectedCount: 0, totalCount: 2 })
})
it('does not request a mutation without selection or after confirmation is cancelled', async () => {
const requested = vi.fn()
server.use(updateSubscribeStatusHandler(1, { success: true }, 200, requested))
await renderList({ listResponse: [movie(1, 'Alpha')] })
await screen.findByText('Alpha')
await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' }))
await fireEvent.click(screen.getByRole('button', { name: 'host-batch-enable' }))
expect(mocks.toastWarning).toHaveBeenCalledWith('请先选择要操作的订阅')
expect(requested).not.toHaveBeenCalled()
mocks.confirm.mockResolvedValueOnce(false)
await fireEvent.click(screen.getByRole('button', { name: 'select-1' }))
await fireEvent.click(screen.getByRole('button', { name: 'host-batch-enable' }))
expect(requested).not.toHaveBeenCalled()
expect(card(1)).toHaveAttribute('data-selected', 'true')
})
it.each([
['enable', 'host-batch-enable', 'R'],
['pause', 'host-batch-pause', 'S'],
])('completes a successful batch %s and sends the expected state query', async (_case, buttonName, state) => {
const requested = vi.fn()
server.use(updateSubscribeStatusHandler(1, { success: true }, 200, requested))
await renderList({ listResponse: [movie(1, 'Alpha')] })
await screen.findByText('Alpha')
await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' }))
await fireEvent.click(screen.getByRole('button', { name: 'select-1' }))
await fireEvent.click(screen.getByRole('button', { name: buttonName }))
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(requested.mock.calls[0][0].pathname).toBe(new URL(subscribeApiUrls.statusById(1)).pathname)
expect(requested.mock.calls[0][0].searchParams.get('state')).toBe(state)
await waitFor(() => expect(batchState()).toMatchObject({ enabled: false, selectedCount: 0 }))
})
it('completes a successful batch delete through the id endpoint', async () => {
const deleted = vi.fn()
server.use(deleteSubscribeByIdHandler(1, { success: true }, 200, deleted))
await renderList({ listResponse: [movie(1, 'Alpha')] })
await screen.findByText('Alpha')
await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' }))
await fireEvent.click(screen.getByRole('button', { name: 'select-1' }))
await fireEvent.click(screen.getByRole('button', { name: 'host-batch-delete' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
await waitFor(() => expect(batchState()).toMatchObject({ enabled: false, selectedCount: 0 }))
})
it.each([
['enable', 'host-batch-enable', '启用'],
['pause', 'host-batch-pause', '暂停'],
])('classifies a %s success false response as a failure', async (_case, buttonName, actionName) => {
const requested = vi.fn()
server.use(updateSubscribeStatusHandler(1, { message: 'rejected', success: false }, 200, requested))
await renderList({ listResponse: [movie(1, 'Alpha')] })
await screen.findByText('Alpha')
await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' }))
await fireEvent.click(screen.getByRole('button', { name: 'select-1' }))
await fireEvent.click(screen.getByRole('button', { name: buttonName }))
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(`${actionName}失败 1 个订阅`))
expect(mocks.toastSuccess).not.toHaveBeenCalled()
await waitFor(() => expect(batchState()).toMatchObject({ enabled: false, selectedCount: 0 }))
})
it('reports mixed status results without changing the existing completion flow', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const firstRequested = vi.fn()
const secondRequested = vi.fn()
server.use(
updateSubscribeStatusHandler(1, { success: true }, 200, firstRequested),
updateSubscribeStatusHandler(2, { message: 'failed', success: false }, 500, secondRequested),
)
await renderList({ listResponse: [movie(1, 'Alpha'), movie(2, 'Beta')] })
await screen.findByText('Alpha')
await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' }))
await fireEvent.click(screen.getByRole('button', { name: 'host-toggle-select-all' }))
await fireEvent.click(screen.getByRole('button', { name: 'host-batch-enable' }))
await waitFor(() => expect(firstRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(secondRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('成功启用 1 个订阅'))
expect(mocks.toastError).toHaveBeenCalledWith('启用失败 1 个订阅')
await waitFor(() => expect(batchState()).toMatchObject({ enabled: false, selectedCount: 0 }))
})
})

View File

@@ -26,7 +26,11 @@ export const subscribeApiUrls = {
episodeGroups: (tmdbId: number) => new URL(`media/groups/${tmdbId}`, API_BASE_URL).href,
filterRuleGroups: new URL('system/setting/UserFilterRuleGroups', API_BASE_URL).href,
queryByMedia: (mediaId: string) => new URL(`subscribe/media/${mediaId}`, API_BASE_URL).href,
list: new URL('subscribe/', API_BASE_URL).href,
orderConfig: (type: SubscribeMediaType) =>
new URL(`user/config/${type === '电影' ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'}`, API_BASE_URL).href,
sites: new URL('site/rss', API_BASE_URL).href,
statusById: (id: number) => new URL(`subscribe/status/${id}`, API_BASE_URL).href,
update: new URL('subscribe/', API_BASE_URL).href,
}
@@ -34,6 +38,54 @@ function jsonResponse(body: JsonBodyType, status: number) {
return HttpResponse.json(body, { status })
}
export function subscribeListHandler(
response: JsonBodyType = [],
status = 200,
onRequest: (url: URL) => void = () => {},
) {
return http.get(subscribeApiUrls.list, ({ request }) => {
onRequest(new URL(request.url))
return jsonResponse(response, status)
})
}
export function subscribeOrderConfigHandler(
type: SubscribeMediaType,
value: JsonBodyType = [],
status = 200,
onRequest: (url: URL) => void = () => {},
) {
return http.get(subscribeApiUrls.orderConfig(type), ({ request }) => {
onRequest(new URL(request.url))
return jsonResponse({ data: { value }, success: status < 400 }, status)
})
}
export function saveSubscribeOrderConfigHandler(
type: SubscribeMediaType,
response: SubscribeMutationResponse = { success: true },
status = 200,
onSave: (payload: { id: number }[], url: URL) => void | Promise<void> = () => {},
) {
return http.post(subscribeApiUrls.orderConfig(type), async ({ request }) => {
const payload = (await request.json()) as { id: number }[]
await onSave(payload, new URL(request.url))
return jsonResponse(response, status)
})
}
export function updateSubscribeStatusHandler(
id: number,
response: SubscribeMutationResponse = { success: true },
status = 200,
onRequest: (url: URL) => void | Promise<void> = () => {},
) {
return http.put(subscribeApiUrls.statusById(id), async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse(response, status)
})
}
export function createSubscribeHandler(
response: SubscribeMutationResponse = { data: { id: 1 }, success: true },
status = 200,

View File

@@ -4,7 +4,7 @@ import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { defineComponent, h, type Component } from 'vue'
import { createMemoryHistory, createRouter, type RouteLocationRaw } from 'vue-router'
import { createMemoryHistory, createRouter, type RouteLocationRaw, type RouteMeta } from 'vue-router'
import { vi } from 'vitest'
type TestingLibraryRenderOptions = NonNullable<Parameters<typeof render>[1]>
@@ -12,6 +12,7 @@ type TestingLibraryRenderOptions = NonNullable<Parameters<typeof render>[1]>
export interface RenderWithProvidersOptions extends Omit<TestingLibraryRenderOptions, 'global'> {
global?: TestingLibraryRenderOptions['global']
initialRoute?: RouteLocationRaw
initialRouteMeta?: RouteMeta
initialState?: Record<string, Record<string, unknown>>
stubActions?: boolean
}
@@ -26,13 +27,14 @@ export async function renderWithProviders(component: Component, options: RenderW
const {
global: globalOptions,
initialRoute = '/',
initialRouteMeta = {},
initialState = {},
stubActions = true,
...renderOptions
} = options
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/:pathMatch(.*)*', component: EmptyRoute }],
routes: [{ path: '/:pathMatch(.*)*', component: EmptyRoute, meta: initialRouteMeta }],
})
await router.push(initialRoute)
i18n.global.locale.value = 'zh-CN'

View File

@@ -268,6 +268,7 @@ export default defineConfig(({ mode }) => ({
},
},
setupFiles: ['./tests/setup.ts'],
testTimeout: 60_000,
unstubGlobals: true,
coverage: {
include: [
@@ -275,7 +276,9 @@ export default defineConfig(({ mode }) => ({
'src/utils/permission.ts',
'src/stores/auth.ts',
'src/pages/recommend.vue',
'src/pages/subscribe.vue',
'src/views/dashboard/MediaRecommend.vue',
'src/views/subscribe/SubscribeListView.vue',
'src/composables/useMediaSubscribe.ts',
'src/components/dialog/SubscribeEditDialog.vue',
],
@@ -305,6 +308,12 @@ export default defineConfig(({ mode }) => ({
lines: 80,
statements: 80,
},
'src/pages/subscribe.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/stores/auth.ts': {
branches: 75,
functions: 80,
@@ -329,6 +338,12 @@ export default defineConfig(({ mode }) => ({
lines: 80,
statements: 80,
},
'src/views/subscribe/SubscribeListView.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
},
},
},