mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-11 00:23:37 +08:00
test(subscribe): cover list management workflows (#535)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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('流行趋势')
|
||||
|
||||
|
||||
467
src/pages/__tests__/subscribe.spec.ts
Normal file
467
src/pages/__tests__/subscribe.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
657
src/views/subscribe/__tests__/SubscribeListView.spec.ts
Normal file
657
src/views/subscribe/__tests__/SubscribeListView.spec.ts
Normal 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 }))
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user