test(subscribe): cover popular and share workflows (#545)

This commit is contained in:
InfinityPacer
2026-07-17 19:54:24 +08:00
committed by GitHub
parent 2b310c9a97
commit 435e9ecfdd
16 changed files with 2122 additions and 123 deletions

View File

@@ -114,6 +114,8 @@ export interface SubscribeShare {
tmdbid?: number
// 豆瓣ID
doubanid?: string
// Bangumi ID
bangumiid?: number
// 季号
season?: number
// 海报

View File

@@ -49,6 +49,7 @@ const posterUrl = computed(() => {
function getMediaId() {
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
}
// 查看媒体详情
@@ -152,7 +153,7 @@ function doDelete() {
</div>
</VCardText>
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-calcdar" class="me-1" />
<VIcon icon="mdi-calendar" class="me-1" />
{{ dateText }}
</VCardText>
</div>

View File

@@ -0,0 +1,180 @@
import { formatDateDifference } from '@/@core/utils/formatters'
import type { SubscribeShare } from '@/api/types'
import SubscribeShareCard from '@/components/cards/SubscribeShareCard.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { createSubscribeShare } from '@tests/support/factories/subscribe'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
openSharedDialog: vi.fn(),
routerPush: vi.fn(),
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/router', () => ({
default: { push: (...args: unknown[]) => mocks.routerPush(...args) },
}))
vi.mock('@iconify/vue', () => ({
Icon: {
props: ['icon'],
template: '<span :data-icon="icon" />',
},
}))
type ShareOverrides = Partial<SubscribeShare>
function observeElementsImmediately() {
class ImmediateIntersectionObserver {
readonly root = null
readonly rootMargin = '0px'
readonly thresholds = [0]
constructor(private readonly callback: IntersectionObserverCallback) {}
disconnect() {}
observe(target: Element) {
this.callback([{ intersectionRatio: 1, isIntersecting: true, target } as IntersectionObserverEntry], this)
}
takeRecords(): IntersectionObserverEntry[] {
return []
}
unobserve() {}
}
vi.stubGlobal('IntersectionObserver', ImmediateIntersectionObserver)
}
async function renderCard(overrides: ShareOverrides = {}, globalImageCache = false) {
const media = createSubscribeShare(overrides)
const result = await renderWithProviders(SubscribeShareCard, {
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: globalImageCache },
initialized: true,
loading: false,
},
},
props: { media },
})
return { ...result, media }
}
async function loadPoster(container: Element) {
const backdrop = container.querySelector<HTMLImageElement>('img')
expect(backdrop).not.toBeNull()
await fireEvent.load(backdrop as HTMLImageElement)
await waitFor(() => expect(container.querySelectorAll('img')).toHaveLength(2))
return container.querySelectorAll<HTMLImageElement>('img')[1]
}
function getDialogCall(index = 0) {
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
unknown,
Record<string, unknown>,
Record<string, (...args: unknown[]) => void>,
Record<string, unknown>,
]
return { events, options, props }
}
describe('SubscribeShareCard', () => {
beforeEach(() => {
observeElementsImmediately()
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
})
it('renders sharing metadata and reveals the cached poster after the backdrop loads', async () => {
const { container, media } = await renderCard({}, true)
expect(screen.getByText(media.share_title!)).toBeInTheDocument()
expect(screen.getByText(media.share_comment!)).toBeInTheDocument()
expect(screen.getByText(media.share_user!)).toBeInTheDocument()
expect(screen.getByText(media.count!.toLocaleString())).toBeInTheDocument()
expect(screen.getByText(formatDateDifference(media.date!))).toBeInTheDocument()
const backdrop = container.querySelector<HTMLImageElement>('img')
expect(backdrop).not.toBeNull()
expect((backdrop as HTMLImageElement).src).toContain('system/cache/image?url=')
expect((backdrop as HTMLImageElement).src).toContain(encodeURIComponent(media.backdrop!))
const poster = await loadPoster(container)
expect(poster.src).toContain('system/cache/image?url=')
expect(poster.src).toContain(encodeURIComponent(media.poster!))
const dateMetadata = screen.getByText(formatDateDifference(media.date!)).closest('.v-card-text')
expect(dateMetadata?.querySelector('[data-icon="mdi-calendar"], [data-icon="mdi:calendar"]')).not.toBeNull()
})
it('falls back to the poster when the backdrop is missing and hides a zero reuse count', async () => {
const { container, media } = await renderCard({ backdrop: undefined, count: 0 })
const backdrop = container.querySelector<HTMLImageElement>('img')
expect(backdrop).not.toBeNull()
expect((backdrop as HTMLImageElement).src).toContain(media.poster!)
expect(screen.queryByText('0')).not.toBeInTheDocument()
})
it.each([
['TMDB before Douban', { doubanid: '2202', tmdbid: 1101 }, 'tmdb:1101'],
['Douban without TMDB', { doubanid: '2202', tmdbid: undefined }, 'douban:2202'],
['Bangumi without TMDB or Douban', { bangumiid: 3303, doubanid: undefined, tmdbid: undefined }, 'bangumi:3303'],
] as const)('routes media details with %s while keeping the fork dialog closed', async (_case, ids, mediaid) => {
const { container, media } = await renderCard(ids)
const poster = await loadPoster(container)
await fireEvent.click(poster)
expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/media',
query: {
mediaid,
title: media.name,
type: media.type,
year: media.year,
},
})
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
})
it('opens the fork dialog with the exact media and replaces it with editing after fork success', async () => {
const { container, media } = await renderCard()
const card = container.querySelector<HTMLElement>('.v-card')
expect(card).not.toBeNull()
await fireEvent.click(card as HTMLElement)
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
const forkDialog = getDialogCall()
expect(forkDialog.props).toEqual({ media })
expect(forkDialog.options).toEqual({ closeOn: ['close', 'fork', 'delete'] })
forkDialog.events.fork(4701)
expect(mocks.openSharedDialog).toHaveBeenCalledTimes(2)
const editDialog = getDialogCall(1)
expect(editDialog.props).toEqual({ subid: 4701 })
expect(editDialog.events).toEqual({})
expect(editDialog.options).toEqual({ closeOn: ['close', 'save', 'remove'] })
})
it('forwards deletion from the fork dialog without depending on its response payload', async () => {
const { container, emitted } = await renderCard()
const card = container.querySelector<HTMLElement>('.v-card')
expect(card).not.toBeNull()
await fireEvent.click(card as HTMLElement)
getDialogCall().events.delete({ id: 9999 })
expect(emitted('delete')).toHaveLength(1)
expect(emitted('delete')?.[0]).toEqual([])
})
})

View File

@@ -54,7 +54,8 @@ async function queryFollowUsers() {
const result: { [key: string]: any } = await api.get('system/setting/public/FollowSubscribers')
followUsers.value = result.data?.value ?? []
} catch (error) {
console.log(error)
console.error(error)
$toast.error(t('subscribe.requestFailed'))
}
}
@@ -66,7 +67,8 @@ async function followUser() {
queryFollowUsers()
}
} catch (error) {
console.log(error)
console.error(error)
$toast.error(t('subscribe.requestFailed'))
}
}
@@ -82,7 +84,8 @@ async function unfollowUser() {
queryFollowUsers()
}
} catch (error) {
console.log(error)
console.error(error)
$toast.error(t('subscribe.requestFailed'))
}
}
@@ -96,6 +99,7 @@ const posterUrl = computed(() => {
function getMediaId() {
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
}
// 查看媒体详情
@@ -129,6 +133,12 @@ async function doFork() {
}
} catch (error) {
console.error(error)
$toast.error(
t('subscribe.addFailed', {
name: props.media?.share_title,
message: t('subscribe.requestFailed'),
}),
)
} finally {
processing.value = false
doneNProgress()
@@ -151,12 +161,13 @@ async function doDelete() {
if (result.success) {
$toast.success(t('subscribe.cancelSuccess'))
// 完成
emit('delete', result.data.id)
emit('delete')
} else {
$toast.error(t('subscribe.cancelFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
$toast.error(t('subscribe.cancelFailed', { message: t('subscribe.requestFailed') }))
} finally {
deleting.value = false
doneNProgress()

View File

@@ -27,7 +27,9 @@ const shareDoing = ref(false)
// 订阅编辑表单
const shareForm = ref<SubscribeShare>({
subscribe_id: props.sub?.id ?? 0,
share_title: `${props.sub?.name} ${formatSeason(props.sub?.season ? props.sub?.season.toString() : '')}`,
share_title: `${props.sub?.name} ${formatSeason(
props.sub?.season === null || props.sub?.season === undefined ? '' : props.sub.season.toString(),
)}`,
})
// 分享订阅
@@ -36,7 +38,6 @@ async function doShare() {
try {
shareDoing.value = true
const result: { [key: string]: any } = await api.post('subscribe/share', shareForm.value)
shareDoing.value = false
// 提示
if (result.success) {
$toast.success(t('dialog.subscribeShare.shareSuccess', { name: props.sub?.name }))
@@ -46,7 +47,15 @@ async function doShare() {
$toast.error(t('dialog.subscribeShare.shareFailed', { name: props.sub?.name, message: result.message }))
}
} catch (e) {
console.log(e)
console.error(e)
$toast.error(
t('dialog.subscribeShare.shareFailed', {
name: props.sub?.name,
message: t('subscribe.requestFailed'),
}),
)
} finally {
shareDoing.value = false
}
}
@@ -64,7 +73,11 @@ const $toast = useToast()
<VCardTitle>{{ t('dialog.subscribeShare.shareSubscription') }}</VCardTitle>
<VCardSubtitle>
{{ props.sub?.name }}
{{ props.sub?.season ? t('dialog.subscribeShare.season', { number: props.sub?.season }) : '' }}
{{
props.sub?.season === null || props.sub?.season === undefined
? ''
: t('dialog.subscribeShare.season', { number: props.sub.season })
}}
</VCardSubtitle>
</VCardItem>
<VDivider />

View File

@@ -23,14 +23,19 @@ const statistics = ref<SubscribeShareStatistics[]>([])
// 是否加载中
const loading = ref(false)
// 本地统计接口是否加载失败;合法空数组仍使用空数据状态。
const loadError = ref(false)
// 获取统计数据
async function fetchStatistics() {
try {
loading.value = true
loadError.value = false
const data: SubscribeShareStatistics[] = await api.get('subscribe/share/statistics')
statistics.value = data
} catch (error) {
console.error('获取分享统计数据失败:', error)
loadError.value = true
} finally {
loading.value = false
}
@@ -130,6 +135,13 @@ onMounted(() => {
<VDivider />
<VCardText class="pa-0">
<LoadingBanner v-if="loading" class="mt-4" />
<div v-else-if="loadError" class="text-center py-8">
<VIcon icon="mdi-alert-circle-outline" size="64" color="error" class="mb-4" />
<div class="text-h6 text-error mb-4">{{ t('subscribe.requestFailed') }}</div>
<VBtn color="primary" variant="tonal" prepend-icon="mdi-refresh" @click="fetchStatistics">
{{ t('common.retry') }}
</VBtn>
</div>
<div v-else-if="rankedStatistics.length === 0" class="text-center py-8">
<VIcon icon="mdi-chart-line" size="64" color="grey" class="mb-4" />
<div class="text-h6 text-grey">{{ t('subscribe.noStatisticsData') }}</div>

View File

@@ -0,0 +1,395 @@
import type { SubscribeShare } from '@/api/types'
import ForkSubscribeDialog from '@/components/dialog/ForkSubscribeDialog.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createSubscribeShare } from '@tests/support/factories/subscribe'
import {
deleteSubscribeShareHandler,
followSubscriberHandler,
followSubscribersSettingHandler,
forkSubscribeHandler,
unfollowSubscriberHandler,
} from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { flushPromises } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
routerPush: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/router', () => ({
default: {
push: (...args: unknown[]) => mocks.routerPush(...args),
},
}))
vi.mock('@/api/nprogress', () => ({
doneNProgress: vi.fn(),
startNProgress: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
const DialogCloseButtonStub = defineComponent({
name: 'VDialogCloseBtn',
emits: ['click'],
setup(_props, { emit }) {
return () => h('button', { type: 'button', onClick: () => emit('click') }, '关闭')
},
})
const PosterStub = defineComponent({
name: 'VImg',
emits: ['click'],
setup(_props, { emit }) {
return () =>
h(
'button',
{
'aria-label': '查看媒体详情',
type: 'button',
onClick: () => emit('click'),
},
'查看媒体详情',
)
},
})
interface MediaIdentifiers {
bangumiid?: number
doubanid?: string
tmdbid?: number
}
const mediaDetailCases: Array<[string, MediaIdentifiers, string]> = [
['TMDB', { tmdbid: 6301 }, 'tmdb:6301'],
['Douban', { doubanid: 'db-6302', tmdbid: undefined }, 'douban:db-6302'],
['Bangumi', { bangumiid: 6303, doubanid: undefined, tmdbid: undefined }, 'bangumi:6303'],
]
function createDeferred() {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
async function renderDialog(
media: SubscribeShare = createSubscribeShare(),
settings: Record<string, unknown> = {},
) {
const events = {
close: vi.fn(),
delete: vi.fn(),
fork: vi.fn(),
}
const result = await renderWithProviders(ForkSubscribeDialog, {
global: {
components: {
VDialogCloseBtn: DialogCloseButtonStub,
},
stubs: {
VImg: PosterStub,
},
},
initialState: {
globalSettings: {
data: {
GLOBAL_IMAGE_CACHE: false,
SUBSCRIBE_SHARE_MANAGE: false,
USER_UNIQUE_ID: 'current-user',
...settings,
},
},
},
props: {
media,
modelValue: true,
onClose: events.close,
onDelete: events.delete,
onFork: events.fork,
},
})
await flushPromises()
await flushPromises()
return { ...result, events, media }
}
describe('ForkSubscribeDialog follow behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('loads the followed users and shows the current action', async () => {
const media = createSubscribeShare({ share_uid: 'followed-user' })
const requested = vi.fn()
server.use(followSubscribersSettingHandler(['followed-user'], 200, requested))
await renderDialog(media)
expect(await screen.findByRole('button', { name: '取消关注' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '关注' })).not.toBeInTheDocument()
expect(requested).toHaveBeenCalledOnce()
})
it('follows a share user and refreshes the action from the server setting', async () => {
const media = createSubscribeShare({ share_uid: 'new-follow-user' })
const users: string[] = []
const writeRequest = vi.fn((url: URL) => {
users.push(url.searchParams.get('share_uid') || '')
})
server.use(
followSubscribersSettingHandler(users),
followSubscriberHandler({ success: true }, 200, writeRequest),
)
const user = userEvent.setup()
await renderDialog(media)
await user.click(await screen.findByRole('button', { name: '关注' }))
expect(await screen.findByRole('button', { name: '取消关注' })).toBeInTheDocument()
expect(writeRequest).toHaveBeenCalledOnce()
expect(writeRequest.mock.calls[0][0].pathname).toBe('/api/v1/subscribe/follow')
expect(writeRequest.mock.calls[0][0].searchParams.get('share_uid')).toBe('new-follow-user')
})
it('unfollows a share user and refreshes the action from the server setting', async () => {
const media = createSubscribeShare({ share_uid: 'old-follow-user' })
const users = ['old-follow-user']
const writeRequest = vi.fn((url: URL) => {
users.splice(users.indexOf(url.searchParams.get('share_uid') || ''), 1)
})
server.use(
followSubscribersSettingHandler(users),
unfollowSubscriberHandler({ success: true }, 200, writeRequest),
)
const user = userEvent.setup()
await renderDialog(media)
await user.click(await screen.findByRole('button', { name: '取消关注' }))
expect(await screen.findByRole('button', { name: '关注' })).toBeInTheDocument()
expect(writeRequest).toHaveBeenCalledOnce()
expect(writeRequest.mock.calls[0][0].pathname).toBe('/api/v1/subscribe/follow')
expect(writeRequest.mock.calls[0][0].searchParams.get('share_uid')).toBe('old-follow-user')
})
it('does not show follow actions when the share has no UID', async () => {
const media = createSubscribeShare({ share_uid: undefined })
server.use(followSubscribersSettingHandler([]))
await renderDialog(media)
await waitFor(() => expect(screen.getByRole('button', { name: '订阅' })).toBeInTheDocument())
expect(screen.queryByRole('button', { name: '关注' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '取消关注' })).not.toBeInTheDocument()
})
it('shows visible feedback when the followed-user list request fails', async () => {
server.use(followSubscribersSettingHandler([], 500))
await renderDialog(createSubscribeShare())
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试'))
})
it('keeps the follow action and shows feedback when the follow request fails', async () => {
const media = createSubscribeShare({ share_uid: 'failed-follow-user' })
server.use(followSubscribersSettingHandler([]), followSubscriberHandler({ success: true }, 500))
const user = userEvent.setup()
await renderDialog(media)
await user.click(await screen.findByRole('button', { name: '关注' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试'))
expect(screen.getByRole('button', { name: '关注' })).toBeInTheDocument()
})
it('keeps the unfollow action and shows feedback when the unfollow request fails', async () => {
const media = createSubscribeShare({ share_uid: 'failed-unfollow-user' })
server.use(
followSubscribersSettingHandler(['failed-unfollow-user']),
unfollowSubscriberHandler({ success: true }, 500),
)
const user = userEvent.setup()
await renderDialog(media)
await user.click(await screen.findByRole('button', { name: '取消关注' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试'))
expect(screen.getByRole('button', { name: '取消关注' })).toBeInTheDocument()
})
})
describe('ForkSubscribeDialog fork, delete, and navigation behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('keeps the fork button pending and emits the created subscription ID on success', async () => {
const media = createSubscribeShare({ id: 6101, share_title: '待复用分享' })
const deferred = createDeferred()
const forkPayload = vi.fn(() => deferred.promise)
server.use(
followSubscribersSettingHandler([]),
forkSubscribeHandler({ data: { id: 7101 }, success: true }, 200, forkPayload),
)
const user = userEvent.setup()
const { events } = await renderDialog(media)
const forkButton = screen.getByRole('button', { name: '订阅' })
await user.click(forkButton)
await waitFor(() => expect(forkPayload).toHaveBeenCalledOnce())
expect(forkButton).toBeDisabled()
expect(events.fork).not.toHaveBeenCalled()
deferred.resolve()
await waitFor(() => expect(events.fork).toHaveBeenCalledWith(7101))
expect(forkPayload).toHaveBeenCalledWith(media)
expect(mocks.toastSuccess).toHaveBeenCalledWith('添加待复用分享成功!')
})
it('reports a fork business failure and does not emit', async () => {
server.use(
followSubscribersSettingHandler([]),
forkSubscribeHandler({ message: '订阅已存在', success: false }),
)
const user = userEvent.setup()
const { events } = await renderDialog(createSubscribeShare({ share_title: '冲突分享' }))
await user.click(screen.getByRole('button', { name: '订阅' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('添加冲突分享失败:订阅已存在!'))
expect(events.fork).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '订阅' })).not.toBeDisabled()
})
it('reports an HTTP fork failure, restores the action, and does not emit', async () => {
server.use(followSubscribersSettingHandler([]), forkSubscribeHandler({ success: true }, 500))
const user = userEvent.setup()
const { events } = await renderDialog(createSubscribeShare())
await user.click(screen.getByRole('button', { name: '订阅' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('请求失败')))
expect(events.fork).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '订阅' })).not.toBeDisabled()
})
it.each([
['the owner', 'owned-share', false, true],
['a share manager', 'other-user', true, true],
['another ordinary user', 'other-user', false, false],
])('shows delete permission for %s', async (_case, shareUid, canManage, visible) => {
server.use(followSubscribersSettingHandler([]))
await renderDialog(createSubscribeShare({ share_uid: shareUid }), {
SUBSCRIBE_SHARE_MANAGE: canManage,
USER_UNIQUE_ID: 'owned-share',
})
await waitFor(() => expect(screen.getByRole('button', { name: '订阅' })).toBeInTheDocument())
if (visible) expect(screen.getByRole('button', { name: '取消分享' })).toBeInTheDocument()
else expect(screen.queryByRole('button', { name: '取消分享' })).not.toBeInTheDocument()
})
it('deletes the exact share and emits success without relying on response data.id', async () => {
const media = createSubscribeShare({ id: 6201, share_uid: 'owned-share' })
const deferred = createDeferred()
const deleteRequest = vi.fn((_url: URL) => deferred.promise)
server.use(
followSubscribersSettingHandler([]),
deleteSubscribeShareHandler(6201, { success: true }, 200, deleteRequest),
)
const user = userEvent.setup()
const { events } = await renderDialog(media, { USER_UNIQUE_ID: 'owned-share' })
const deleteButton = screen.getByRole('button', { name: '取消分享' })
await user.click(deleteButton)
await waitFor(() => expect(deleteRequest).toHaveBeenCalledOnce())
expect(deleteButton).toBeDisabled()
expect(deleteRequest.mock.calls[0][0].pathname).toBe('/api/v1/subscribe/share/6201')
deferred.resolve()
await waitFor(() => expect(events.delete).toHaveBeenCalledOnce())
expect(events.delete).toHaveBeenCalledWith()
expect(mocks.toastSuccess).toHaveBeenCalledWith('已取消订阅!')
})
it('reports a delete business failure and does not emit', async () => {
const media = createSubscribeShare({ id: 6202, share_uid: 'owned-share' })
server.use(
followSubscribersSettingHandler([]),
deleteSubscribeShareHandler(6202, { message: '没有删除权限', success: false }),
)
const user = userEvent.setup()
const { events } = await renderDialog(media, { USER_UNIQUE_ID: 'owned-share' })
await user.click(screen.getByRole('button', { name: '取消分享' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('取消订阅失败:没有删除权限!'))
expect(events.delete).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '取消分享' })).not.toBeDisabled()
})
it('reports an HTTP delete failure, restores the action, and does not emit', async () => {
const media = createSubscribeShare({ id: 6203, share_uid: 'owned-share' })
server.use(followSubscribersSettingHandler([]), deleteSubscribeShareHandler(6203, { success: true }, 500))
const user = userEvent.setup()
const { events } = await renderDialog(media, { USER_UNIQUE_ID: 'owned-share' })
await user.click(screen.getByRole('button', { name: '取消分享' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('请求失败')))
expect(events.delete).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '取消分享' })).not.toBeDisabled()
})
it('emits close from the dialog close control', async () => {
server.use(followSubscribersSettingHandler([]))
const user = userEvent.setup()
const { events } = await renderDialog(createSubscribeShare())
await user.click(screen.getByRole('button', { name: '关闭' }))
expect(events.close).toHaveBeenCalledOnce()
})
it.each(mediaDetailCases)('routes %s shares to their media details', async (_source, identifiers, expectedMediaId) => {
const media: SubscribeShare = {
...createSubscribeShare({
doubanid: identifiers.doubanid,
tmdbid: identifiers.tmdbid,
}),
bangumiid: identifiers.bangumiid,
}
server.use(followSubscribersSettingHandler([]))
const user = userEvent.setup()
await renderDialog(media)
await user.click(screen.getByRole('button', { name: '查看媒体详情' }))
expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/media',
query: {
mediaid: expectedMediaId,
title: media.name,
type: media.type,
year: media.year,
},
})
})
})

View File

@@ -0,0 +1,170 @@
import SubscribeShareDialog from '@/components/dialog/SubscribeShareDialog.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createSubscribe } from '@tests/support/factories/subscribe'
import { shareSubscribeHandler } from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { flushPromises } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
const DialogCloseButtonStub = defineComponent({
name: 'VDialogCloseBtn',
emits: ['click'],
setup(_props, { emit }) {
return () => h('button', { type: 'button', onClick: () => emit('click') }, '关闭')
},
})
function createDeferred() {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
async function renderDialog(season = 2, name = '分享创建测试剧') {
const close = vi.fn()
const sub = createSubscribe({
id: 5201,
name,
season,
tmdbid: 52010,
type: '电视剧',
})
const result = await renderWithProviders(SubscribeShareDialog, {
props: {
modelValue: true,
onClose: close,
sub,
},
global: {
components: {
VDialogCloseBtn: DialogCloseButtonStub,
},
},
})
return { ...result, close, sub }
}
async function fillRequiredFields(comment = '覆盖精确订阅规则', shareUser = '测试分享人') {
const user = userEvent.setup()
await user.type(screen.getByLabelText('说明'), comment)
await user.type(screen.getByLabelText('分享用户'), shareUser)
return user
}
describe('SubscribeShareDialog', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it.each([
['normal season', 2, 'S02', '第 2 季'],
['special season zero', 0, 'S00', '第 0 季'],
])('renders the readonly default title and subtitle for %s', async (_case, season, seasonLabel, subtitle) => {
const { sub } = await renderDialog(season)
expect(screen.getByLabelText('标题')).toHaveValue(`${sub.name} ${seasonLabel}`)
expect(document.querySelector('.v-card-subtitle')).toHaveTextContent(`${sub.name} ${subtitle}`)
})
it.each([
['description', '', '测试分享人'],
['sharing user', '覆盖精确订阅规则', ''],
])('blocks submission when the %s is missing', async (_case, comment, shareUser) => {
const requested = vi.fn()
server.use(shareSubscribeHandler({ success: true }, 200, requested))
await renderDialog()
const user = userEvent.setup()
if (comment) await user.type(screen.getByLabelText('说明'), comment)
if (shareUser) await user.type(screen.getByLabelText('分享用户'), shareUser)
await user.click(screen.getByRole('button', { name: '确认分享' }))
await flushPromises()
expect(requested).not.toHaveBeenCalled()
expect(mocks.toastSuccess).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('submits the exact payload once, stays pending, then closes after success', async () => {
const deferred = createDeferred()
const payloads: unknown[] = []
server.use(
shareSubscribeHandler({ success: true }, 200, async payload => {
payloads.push(payload)
await deferred.promise
}),
)
const { close, sub } = await renderDialog()
const user = await fillRequiredFields('只保留精确字段', '分享者甲')
const submit = screen.getByRole('button', { name: '确认分享' })
await user.click(submit)
await waitFor(() => expect(payloads).toHaveLength(1))
expect(payloads[0]).toEqual({
share_comment: '只保留精确字段',
share_title: `${sub.name} S02`,
share_user: '分享者甲',
subscribe_id: sub.id,
})
expect(submit).toBeDisabled()
expect(close).not.toHaveBeenCalled()
deferred.resolve()
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith(`${sub.name} 分享成功!`))
expect(close).toHaveBeenCalledOnce()
expect(submit).not.toBeDisabled()
})
it('keeps the dialog open and reports a business failure', async () => {
server.use(shareSubscribeHandler({ message: '远端拒绝', success: false }))
const { close, sub } = await renderDialog()
const user = await fillRequiredFields()
const submit = screen.getByRole('button', { name: '确认分享' })
await user.click(submit)
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(`${sub.name} 分享失败:远端拒绝!`))
expect(close).not.toHaveBeenCalled()
expect(submit).not.toBeDisabled()
})
it('recovers from an HTTP failure, keeps the dialog open, and reports the failure', async () => {
server.use(shareSubscribeHandler({ message: '服务异常', success: false }, 500))
const { close, sub } = await renderDialog(2, 'HTTP失败剧')
const user = await fillRequiredFields()
const submit = screen.getByRole('button', { name: '确认分享' })
await user.click(submit)
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining(`${sub.name} 分享失败`)))
expect(close).not.toHaveBeenCalled()
expect(submit).not.toBeDisabled()
})
it('emits close from the dialog close control', async () => {
const user = userEvent.setup()
const { close } = await renderDialog()
await user.click(screen.getByRole('button', { name: '关闭' }))
expect(close).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,174 @@
import SubscribeShareStatisticsDialog from '@/components/dialog/SubscribeShareStatisticsDialog.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createSubscribeShareStatistics } from '@tests/support/factories/subscribe'
import { subscribeShareStatisticsHandler } from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const DialogCloseButtonStub = defineComponent({
name: 'VDialogCloseBtn',
emits: ['click'],
setup(_props, { emit }) {
return () => h('button', { type: 'button', onClick: () => emit('click') }, '关闭')
},
})
const LoadingBannerStub = defineComponent({
name: 'LoadingBanner',
setup() {
return () => h('div', { role: 'status' }, '加载中')
},
})
function createDeferred() {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
async function renderDialog() {
const close = vi.fn()
const result = await renderWithProviders(SubscribeShareStatisticsDialog, {
global: {
components: {
VDialogCloseBtn: DialogCloseButtonStub,
},
stubs: {
LoadingBanner: LoadingBannerStub,
},
},
props: {
modelValue: true,
onClose: close,
},
})
return { ...result, close }
}
describe('SubscribeShareStatisticsDialog', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('shows loading until statistics arrive', async () => {
const deferred = createDeferred()
const requested = vi.fn(() => deferred.promise)
server.use(
subscribeShareStatisticsHandler(
[createSubscribeShareStatistics({ share_user: '延迟统计用户' })],
200,
requested,
),
)
renderDialog()
expect(await screen.findByRole('status')).toHaveTextContent('加载中')
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
deferred.resolve()
expect((await screen.findAllByText('延迟统计用户')).length).toBeGreaterThan(0)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('sorts by reuse count and preserves source order for ties', async () => {
server.use(
subscribeShareStatisticsHandler([
createSubscribeShareStatistics({ share_user: '并列第一甲', total_reuse_count: 50 }),
createSubscribeShareStatistics({ share_user: '并列第一乙', total_reuse_count: 50 }),
createSubscribeShareStatistics({ share_user: '第三名', total_reuse_count: 30 }),
createSubscribeShareStatistics({ share_user: '并列末位甲', total_reuse_count: 10 }),
createSubscribeShareStatistics({ share_user: '并列末位乙', total_reuse_count: 10 }),
]),
)
await renderDialog()
expect(await screen.findByText('#4')).toBeInTheDocument()
const firstPlace = document.body.querySelector('.first-place')?.parentElement
expect(firstPlace).toHaveTextContent('并列第一甲')
expect(firstPlace).not.toHaveTextContent('并列第一乙')
const renderedText = document.body.textContent || ''
expect(renderedText.indexOf('并列末位甲')).toBeLessThan(renderedText.indexOf('并列末位乙'))
expect(screen.getByText('#5')).toBeInTheDocument()
})
it.each([1, 2, 3, 4])('renders a complete ranking with %i participant(s)', async count => {
const statistics = Array.from({ length: count }, (_, index) =>
createSubscribeShareStatistics({
share_user: `人数场景用户 ${count}-${index + 1}`,
total_reuse_count: count - index,
}),
)
server.use(subscribeShareStatisticsHandler(statistics))
await renderDialog()
for (const item of statistics) {
expect((await screen.findAllByText(item.share_user!)).length).toBeGreaterThan(0)
}
expect(screen.queryByText('暂无分享统计数据')).not.toBeInTheDocument()
if (count === 4) expect(screen.getByText('#4')).toBeInTheDocument()
})
it('renders missing aggregate fields across the podium and remaining rankings', async () => {
const statistics = Array.from({ length: 4 }, () =>
createSubscribeShareStatistics({
share_count: undefined,
share_user: undefined,
total_reuse_count: undefined,
}),
)
server.use(subscribeShareStatisticsHandler(statistics))
await renderDialog()
expect((await screen.findAllByText('未知')).length).toBeGreaterThan(0)
expect(screen.getAllByText('0').length).toBeGreaterThanOrEqual(2)
expect(screen.getByText('#4')).toBeInTheDocument()
})
it('treats an empty statistics list as valid empty data', async () => {
server.use(subscribeShareStatisticsHandler([]))
await renderDialog()
expect(await screen.findByText('暂无分享统计数据')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('shows an HTTP error separately and retries the same request on demand', async () => {
const failedRequest = vi.fn()
server.use(subscribeShareStatisticsHandler([], 500, failedRequest))
const user = userEvent.setup()
await renderDialog()
expect(await screen.findByText('请求失败,请稍后重试')).toBeInTheDocument()
expect(failedRequest).toHaveBeenCalledOnce()
server.use(
subscribeShareStatisticsHandler([
createSubscribeShareStatistics({ share_user: '重试恢复用户', total_reuse_count: 20 }),
]),
)
await user.click(screen.getByRole('button', { name: '重试' }))
expect((await screen.findAllByText('重试恢复用户')).length).toBeGreaterThan(0)
expect(screen.queryByText('请求失败,请稍后重试')).not.toBeInTheDocument()
})
it('emits close from the dialog close control', async () => {
server.use(subscribeShareStatisticsHandler([]))
const user = userEvent.setup()
const { close } = await renderDialog()
await user.click(screen.getByRole('button', { name: '关闭' }))
expect(close).toHaveBeenCalledOnce()
})
})

View File

@@ -25,15 +25,18 @@ const apipath = 'subscribe/popular'
// 当前页码
const page = ref(1)
// 是否加载中
const loading = ref(false)
// 是否加载完成
const isRefreshed = ref(false)
// 当前列表请求是否失败;合法空数组仍使用空数据状态。
const loadError = ref(false)
// 数据列表
const dataList = ref<MediaInfo[]>([])
const currData = ref<MediaInfo[]>([])
// 筛选重置允许新旧请求短暂并行,只接纳当前代次的响应。
let requestGeneration = 0
const loadingGenerations = new Set<number>()
// 筛选参数
const filterParams = reactive({
@@ -48,9 +51,11 @@ const filterParams = reactive({
const currentKey = ref(0)
function resetData() {
requestGeneration++
dataList.value = []
page.value = 1
isRefreshed.value = false
loadError.value = false
currentKey.value++
}
@@ -140,69 +145,62 @@ function getParams() {
}
// 获取列表数据
async function fetchData({ done }: { done: any }) {
try {
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
return
}
async function fetchData({ done }: { done: (status: 'empty' | 'error' | 'ok') => void }) {
const generation = requestGeneration
// 加载到满屏或者加载出错
if (!hasScroll()) {
// 加载多次
while (!hasScroll()) {
// 设置加载中
loading.value = true
// 请求API
currData.value = await api.get(apipath, {
params: getParams(),
})
// 取消加载中
loading.value = false
// 标计为已请求完成
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
done('empty')
return
}
// 合并数据
dataList.value = [...dataList.value, ...currData.value]
// 页码+1
page.value++
// 返回加载成功
done('ok')
await nextTick()
}
} else {
// 设置加载中
loading.value = true
// 请求API
currData.value = await api.get(apipath, {
// 同一筛选条件只允许一个分页请求在途。
if (loadingGenerations.has(generation)) {
return
}
loadingGenerations.add(generation)
loadError.value = false
try {
while (generation === requestGeneration) {
const currentData: MediaInfo[] = await api.get(apipath, {
params: getParams(),
})
loading.value = false
// 标计为已请求完成
if (generation !== requestGeneration) return
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
if (currentData.length === 0) {
done('empty')
} else {
// 合并数据
dataList.value = [...dataList.value, ...currData.value]
// 页码+1
page.value++
// 返回加载成功
done('ok')
return
}
dataList.value = [...dataList.value, ...currentData]
page.value++
done('ok')
await nextTick()
if (hasScroll()) return
}
} catch (error) {
if (generation !== requestGeneration) return
console.error(error)
// 返回加载失败
isRefreshed.value = true
loadError.value = true
done('error')
} finally {
loadingGenerations.delete(generation)
}
}
/** 使用媒体来源、稳定 ID 与季号区分热门条目。 */
function getMediaItemKey(item: MediaInfo) {
const mediaId = item.tmdb_id
? `tmdb:${item.tmdb_id}`
: item.douban_id
? `douban:${item.douban_id}`
: item.bangumi_id
? `bangumi:${item.bangumi_id}`
: `${item.mediaid_prefix ?? 'media'}:${item.media_id ?? item.title ?? ''}`
return `${item.source ?? 'unknown'}:${mediaId}:season:${item.season ?? 'all'}`
}
</script>
<template>
@@ -278,11 +276,19 @@ async function fetchData({ done }: { done: any }) {
:key="currentKey"
>
<template #loading />
<template #error="{ props: retryProps }">
<div class="d-flex flex-column align-center ga-2 py-4" role="alert">
<span class="text-medium-emphasis">{{ t('subscribe.requestFailed') }}</span>
<VBtn v-bind="retryProps" prepend-icon="mdi-refresh" size="small" variant="tonal">
{{ t('common.retry') }}
</VBtn>
</div>
</template>
<template #empty />
<ProgressiveCardGrid
v-if="dataList.length > 0"
:items="dataList"
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
:get-item-key="getMediaItemKey"
:min-item-width="144"
:estimated-item-height="320"
tabindex="0"
@@ -298,7 +304,7 @@ async function fetchData({ done }: { done: any }) {
</template>
</ProgressiveCardGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
v-if="dataList.length === 0 && isRefreshed && !loadError"
error-code="404"
:error-title="t('common.noData')"
:error-description="t('subscribe.noPopularData')"

View File

@@ -41,9 +41,11 @@ const filterParams = reactive({
const currentKey = ref(0)
function resetData() {
requestGeneration++
dataList.value = []
page.value = 1
isRefreshed.value = false
loadError.value = false
currentKey.value++
}
@@ -114,15 +116,18 @@ watch(
{ deep: true },
)
// 是否加载中
const loading = ref(false)
// 是否加载完成
const isRefreshed = ref(false)
// 当前列表请求是否失败;合法空数组仍使用空数据状态。
const loadError = ref(false)
// 数据列表
const dataList = ref<SubscribeShare[]>([])
const currData = ref<SubscribeShare[]>([])
// 搜索或筛选重置允许新旧请求短暂并行,只接纳当前代次的响应。
let requestGeneration = 0
const loadingGenerations = new Set<number>()
// 拼装参数
function getParams() {
@@ -150,67 +155,47 @@ function getParams() {
}
// 获取列表数据
async function fetchData({ done }: { done: any }) {
try {
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
return
}
async function fetchData({ done }: { done: (status: 'empty' | 'error' | 'ok') => void }) {
const generation = requestGeneration
// 加载到满屏或者加载出错
if (!hasScroll()) {
// 加载多次
while (!hasScroll()) {
// 设置加载中
loading.value = true
// 请求API
currData.value = await api.get(apipath, {
params: getParams(),
})
// 取消加载中
loading.value = false
// 标计为已请求完成
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
done('empty')
return
}
// 合并数据
dataList.value = [...dataList.value, ...currData.value]
// 页码+1
page.value++
// 返回加载成功
done('ok')
await nextTick()
}
} else {
// 设置加载中
loading.value = true
// 请求API
currData.value = await api.get(apipath, {
// 同一搜索条件只允许一个分页请求在途。
if (loadingGenerations.has(generation)) {
return
}
loadingGenerations.add(generation)
loadError.value = false
try {
while (generation === requestGeneration) {
const currentData: SubscribeShare[] = await api.get(apipath, {
params: getParams(),
})
loading.value = false
// 标计为已请求完成
if (generation !== requestGeneration) return
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
if (currentData.length === 0) {
done('empty')
} else {
// 合并数据
dataList.value = [...dataList.value, ...currData.value]
// 页码+1
page.value++
// 返回加载成功
done('ok')
return
}
dataList.value = [...dataList.value, ...currentData]
page.value++
done('ok')
await nextTick()
if (hasScroll()) return
}
} catch (error) {
if (generation !== requestGeneration) return
console.error(error)
// 返回加载失败
isRefreshed.value = true
loadError.value = true
done('error')
} finally {
loadingGenerations.delete(generation)
}
}
@@ -294,6 +279,14 @@ function removeData(id: number) {
:key="currentKey"
>
<template #loading />
<template #error="{ props: retryProps }">
<div class="d-flex flex-column align-center ga-2 py-4" role="alert">
<span class="text-medium-emphasis">{{ t('subscribe.requestFailed') }}</span>
<VBtn v-bind="retryProps" prepend-icon="mdi-refresh" size="small" variant="tonal">
{{ t('common.retry') }}
</VBtn>
</div>
</template>
<template #empty />
<ProgressiveCardGrid
v-if="dataList.length > 0"
@@ -308,7 +301,7 @@ function removeData(id: number) {
</template>
</ProgressiveCardGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
v-if="dataList.length === 0 && isRefreshed && !loadError"
error-code="404"
:error-title="t('common.noData')"
:error-description="keyword ? t('common.noContent') : t('subscribe.noShareData')"

View File

@@ -0,0 +1,401 @@
import type { MediaInfo } from '@/api/types'
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createSubscribeMovie, createSubscribeTv } from '@tests/support/factories/subscribe'
import { popularSubscribesHandler, subscribeApiUrls } from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { flushPromises } from '@vue/test-utils'
import { HttpResponse, http } from 'msw'
import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
const InfiniteScrollStub = defineComponent({
name: 'VInfiniteScroll',
emits: ['load'],
setup(_props, { emit, slots }) {
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
function load() {
status.value = 'loading'
emit('load', {
done(nextStatus: InfiniteScrollStatus) {
status.value = nextStatus === 'ok' ? 'idle' : nextStatus
},
})
}
onMounted(load)
return () =>
h('section', { 'aria-label': '热门订阅无限列表' }, [
h('output', { 'aria-label': '热门订阅无限列表状态' }, status.value),
status.value === 'loading' ? slots.loading?.({}) : null,
status.value === 'error'
? slots.error?.({
side: 'end',
props: { color: undefined, onClick: load },
})
: null,
status.value === 'empty' ? slots.empty?.({}) : null,
slots.default?.(),
h(
'button',
{
'aria-label': '触发热门订阅加载',
onClick: load,
type: 'button',
},
'触发热门订阅加载',
),
])
},
})
const ProgressiveCardGridStub = defineComponent({
name: 'ProgressiveCardGrid',
props: {
getItemKey: {
type: Function as PropType<(item: MediaInfo, index: number) => string | number>,
required: true,
},
items: {
type: Array as PropType<MediaInfo[]>,
required: true,
},
},
setup(props, { slots }) {
return () =>
h('section', { 'aria-label': '热门订阅渐进网格' }, [
h(
'output',
{ 'aria-label': '热门订阅渐进网格键' },
props.items.map((item, index) => String(props.getItemKey(item, index))).join('|'),
),
...props.items.flatMap(item => slots.default?.({ item }) ?? []),
])
},
})
const MediaCardStub = defineComponent({
name: 'MediaCard',
props: {
media: {
type: Object as PropType<MediaInfo>,
required: true,
},
},
setup(props) {
return () => h('article', props.media.title)
},
})
const LoadingBannerStub = defineComponent({
name: 'LoadingBanner',
template: '<div role="status">正在加载热门订阅</div>',
})
const NoDataFoundStub = defineComponent({
name: 'NoDataFound',
props: {
errorDescription: String,
errorTitle: String,
},
template: '<section aria-label="热门订阅空态">{{ errorTitle }} {{ errorDescription }}</section>',
})
interface Deferred {
promise: Promise<void>
resolve: () => void
}
function createDeferred(): Deferred {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
function setHasScroll(hasScroll: boolean) {
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(600)
return vi.spyOn(document.body, 'scrollHeight', 'get').mockReturnValue(hasScroll ? 900 : 500)
}
async function renderPopular(type: '电影' | '电视剧' = '电影') {
return renderWithProviders(SubscribePopularView, {
props: { type },
global: {
stubs: {
LoadingBanner: LoadingBannerStub,
MediaCard: MediaCardStub,
NoDataFound: NoDataFoundStub,
ProgressiveCardGrid: ProgressiveCardGridStub,
VInfiniteScroll: InfiniteScrollStub,
},
},
})
}
describe('SubscribePopularView', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
setHasScroll(true)
})
it.each([
['电影', () => createSubscribeMovie({ popularity: 18, title: '默认热门电影' })],
['电视剧', () => createSubscribeTv({ popularity: 27, title: '默认热门剧集' })],
] as const)('loads %s with the exact default query', async (type, createMedia) => {
const requests: URL[] = []
server.use(
popularSubscribesHandler([createMedia()], 200, url => {
requests.push(url)
}),
)
await renderPopular(type)
expect(await screen.findByText(type === '电影' ? '默认热门电影' : '默认热门剧集')).toBeInTheDocument()
expect(screen.getByText(type === '电影' ? '18' : '27')).toBeInTheDocument()
expect(requests).toHaveLength(1)
expect(requests[0].searchParams.get('stype')).toBe(type)
expect(requests[0].searchParams.get('page')).toBe('1')
expect(requests[0].searchParams.get('count')).toBe('30')
expect(requests[0].searchParams.get('sort_type')).toBe('count')
expect(requests[0].searchParams.get('genre_id')).toBeNull()
expect(requests[0].searchParams.get('min_rating')).toBeNull()
expect(requests[0].searchParams.get('max_rating')).toBeNull()
expect(requests[0].searchParams.get('min_sub')).toBeNull()
})
it('resets to page one with exact sort, genre and rating filters', async () => {
const requests: URL[] = []
server.use(
http.get(subscribeApiUrls.popular, ({ request }) => {
const url = new URL(request.url)
requests.push(url)
const title = url.searchParams.has('min_rating')
? '高分热门结果'
: url.searchParams.has('genre_id')
? '动作热门结果'
: url.searchParams.get('sort_type') === 'time'
? '最新热门结果'
: '默认热门结果'
return HttpResponse.json([createSubscribeMovie({ title })])
}),
)
const user = userEvent.setup()
await renderPopular()
expect(await screen.findByText('默认热门结果')).toBeInTheDocument()
await user.click(screen.getByText('最新'))
expect(await screen.findByText('最新热门结果')).toBeInTheDocument()
expect(screen.queryByText('默认热门结果')).not.toBeInTheDocument()
await user.click(screen.getByText('动作'))
expect(await screen.findByText('动作热门结果')).toBeInTheDocument()
expect(screen.queryByText('最新热门结果')).not.toBeInTheDocument()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}'.repeat(7))
expect(await screen.findByText('高分热门结果')).toBeInTheDocument()
expect(screen.queryByText('动作热门结果')).not.toBeInTheDocument()
expect(requests.length).toBeGreaterThanOrEqual(4)
expect(requests.slice(1).every(url => url.searchParams.get('page') === '1')).toBe(true)
expect(requests[1].searchParams.get('sort_type')).toBe('time')
expect(requests[2].searchParams.get('genre_id')).toBe('28')
expect(requests.at(-1)?.searchParams.get('min_rating')).toBe('7')
})
it('appends later pages and stops when a page is empty', async () => {
const first = createSubscribeMovie({ title: '热门第一页' })
const second = createSubscribeMovie({ title: '热门第二页' })
const requestedPages: string[] = []
server.use(
http.get(subscribeApiUrls.popular, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first])
if (page === '2') return HttpResponse.json([second])
return HttpResponse.json([])
}),
)
const user = userEvent.setup()
await renderPopular()
expect(await screen.findByText('热门第一页')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '触发热门订阅加载' }))
expect(await screen.findByText('热门第二页')).toBeInTheDocument()
expect(screen.getByText('热门第一页')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '触发热门订阅加载' }))
await waitFor(() => expect(requestedPages).toEqual(['1', '2', '3']))
expect(screen.getByText('热门第一页')).toBeInTheDocument()
expect(screen.getByText('热门第二页')).toBeInTheDocument()
})
it('loads consecutive pages until an underfilled viewport becomes scrollable', async () => {
const first = createSubscribeMovie({ title: '未满屏第一页' })
const second = createSubscribeMovie({ title: '未满屏第二页' })
const requestedPages: string[] = []
const scrollHeight = vi.spyOn(document.body, 'scrollHeight', 'get').mockImplementation(() =>
requestedPages.length >= 2 ? 900 : 500,
)
server.use(
http.get(subscribeApiUrls.popular, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
return HttpResponse.json(page === '1' ? [first] : [second])
}),
)
await renderPopular()
expect(await screen.findByText('未满屏第一页')).toBeInTheDocument()
expect(await screen.findByText('未满屏第二页')).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '2'])
scrollHeight.mockRestore()
})
it('shows the no-data state for an empty first page', async () => {
server.use(popularSubscribesHandler([]))
await renderPopular()
expect(await screen.findByRole('region', { name: '热门订阅空态' })).toBeInTheDocument()
expect(screen.queryByText('正在加载热门订阅')).not.toBeInTheDocument()
})
it('deduplicates concurrent load events while the request is pending', async () => {
const gate = createDeferred()
const requests: URL[] = []
server.use(
popularSubscribesHandler([createSubscribeMovie({ title: '并发加载结果' })], 200, async url => {
requests.push(url)
await gate.promise
}),
)
const user = userEvent.setup()
await renderPopular()
await waitFor(() => expect(requests).toHaveLength(1))
await user.click(screen.getByRole('button', { name: '触发热门订阅加载' }))
await user.click(screen.getByRole('button', { name: '触发热门订阅加载' }))
expect(requests).toHaveLength(1)
expect(screen.getByRole('status', { name: '热门订阅无限列表状态' })).toHaveTextContent('loading')
gate.resolve()
expect(await screen.findByText('并发加载结果')).toBeInTheDocument()
})
it('recovers from an initial HTTP failure by retrying page one', async () => {
const requestedPages: string[] = []
server.use(
http.get(subscribeApiUrls.popular, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (requestedPages.length === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([createSubscribeMovie({ title: '热门首载重试成功' })])
}),
)
const user = userEvent.setup()
await renderPopular()
const retry = await screen.findByRole('button', { name: '重试' })
expect(screen.getByRole('alert')).toBeInTheDocument()
await user.click(retry)
expect(await screen.findByText('热门首载重试成功')).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '1'])
})
it('keeps existing cards and retries the failed later page', async () => {
const first = createSubscribeMovie({ title: '热门保留第一页' })
const second = createSubscribeMovie({ title: '热门第二页重试成功' })
const requestedPages: string[] = []
let pageTwoAttempts = 0
server.use(
http.get(subscribeApiUrls.popular, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first])
pageTwoAttempts += 1
if (pageTwoAttempts === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([second])
}),
)
const user = userEvent.setup()
await renderPopular()
expect(await screen.findByText('热门保留第一页')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '触发热门订阅加载' }))
const retry = await screen.findByRole('button', { name: '重试' })
expect(screen.getByText('热门保留第一页')).toBeInTheDocument()
await user.click(retry)
expect(await screen.findByText('热门第二页重试成功')).toBeInTheDocument()
expect(screen.getByText('热门保留第一页')).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '2', '2'])
})
it('ignores an obsolete response when filters reset a pending request', async () => {
const gate = createDeferred()
const requests: URL[] = []
const staleResponse = vi.fn()
server.use(
http.get(subscribeApiUrls.popular, async ({ request }) => {
const url = new URL(request.url)
requests.push(url)
if (!url.searchParams.has('genre_id')) {
await gate.promise
staleResponse()
return HttpResponse.json([createSubscribeMovie({ title: '过期热门结果' })])
}
return HttpResponse.json([createSubscribeMovie({ title: '新筛选热门结果' })])
}),
)
const user = userEvent.setup()
await renderPopular()
await waitFor(() => expect(requests).toHaveLength(1))
await user.click(screen.getByText('动作'))
expect(await screen.findByText('新筛选热门结果')).toBeInTheDocument()
expect(requests.filter(url => url.searchParams.get('genre_id') === '28')).toHaveLength(1)
expect(requests.at(-1)?.searchParams.get('page')).toBe('1')
gate.resolve()
await waitFor(() => expect(staleResponse).toHaveBeenCalledOnce())
await flushPromises()
await flushPromises()
expect(screen.getByText('新筛选热门结果')).toBeInTheDocument()
expect(screen.queryByText('过期热门结果')).not.toBeInTheDocument()
})
it('provides unique progressive-grid keys for different seasons of the same TMDB title', async () => {
server.use(
popularSubscribesHandler([
createSubscribeTv({ season: 1, source: undefined, title: '同剧第一季', tmdb_id: 880 }),
createSubscribeTv({ season: 2, source: undefined, title: '同剧第二季', tmdb_id: 880 }),
]),
)
await renderPopular('电视剧')
expect(await screen.findByText('同剧第一季')).toBeInTheDocument()
expect(screen.getByText('同剧第二季')).toBeInTheDocument()
const keys = screen.getByRole('status', { name: '热门订阅渐进网格键' }).textContent?.split('|') ?? []
expect(keys).toEqual(['unknown:tmdb:880:season:1', 'unknown:tmdb:880:season:2'])
})
})

View File

@@ -0,0 +1,441 @@
import type { SubscribeShare } from '@/api/types'
import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createSubscribeShare } from '@tests/support/factories/subscribe'
import { subscribeApiUrls, subscribeSharesHandler } from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { flushPromises } from '@vue/test-utils'
import { HttpResponse, http } from 'msw'
import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
const InfiniteScrollStub = defineComponent({
name: 'VInfiniteScroll',
emits: ['load'],
setup(_props, { emit, slots }) {
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
function load() {
status.value = 'loading'
emit('load', {
done(nextStatus: InfiniteScrollStatus) {
status.value = nextStatus === 'ok' ? 'idle' : nextStatus
},
})
}
onMounted(load)
return () =>
h('section', { 'aria-label': '订阅分享无限列表' }, [
h('output', { 'aria-label': '订阅分享无限列表状态' }, status.value),
status.value === 'loading' ? slots.loading?.({}) : null,
status.value === 'error'
? slots.error?.({
side: 'end',
props: { color: undefined, onClick: load },
})
: null,
status.value === 'empty' ? slots.empty?.({}) : null,
slots.default?.(),
h(
'button',
{
'aria-label': '触发订阅分享加载',
onClick: load,
type: 'button',
},
'触发订阅分享加载',
),
])
},
})
const ProgressiveCardGridStub = defineComponent({
name: 'ProgressiveCardGrid',
props: {
getItemKey: {
type: Function as PropType<(item: SubscribeShare, index: number) => string | number>,
required: true,
},
items: {
type: Array as PropType<SubscribeShare[]>,
required: true,
},
},
setup(props, { slots }) {
return () =>
h('section', { 'aria-label': '订阅分享渐进网格' }, [
h(
'output',
{ 'aria-label': '订阅分享渐进网格键' },
props.items.map((item, index) => String(props.getItemKey(item, index))).join('|'),
),
...props.items.flatMap(item => slots.default?.({ item }) ?? []),
])
},
})
const SubscribeShareCardStub = defineComponent({
name: 'SubscribeShareCard',
props: {
media: {
type: Object as PropType<SubscribeShare>,
required: true,
},
},
emits: ['delete'],
setup(props, { emit }) {
return () =>
h('article', [
h('span', props.media.share_title),
h(
'button',
{
'aria-label': `删除分享 ${props.media.id}`,
onClick: () => emit('delete'),
type: 'button',
},
'删除',
),
])
},
})
const LoadingBannerStub = defineComponent({
name: 'LoadingBanner',
template: '<div role="status">正在加载订阅分享</div>',
})
const NoDataFoundStub = defineComponent({
name: 'NoDataFound',
props: {
errorDescription: String,
errorTitle: String,
},
template: '<section aria-label="订阅分享空态">{{ errorTitle }} {{ errorDescription }}</section>',
})
const PageContentTitleStub = defineComponent({
name: 'VPageContentTitle',
props: { title: String },
template: '<h2>{{ title }}</h2>',
})
interface Deferred {
promise: Promise<void>
resolve: () => void
}
function createDeferred(): Deferred {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
function setHasScroll(hasScroll: boolean) {
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(600)
return vi.spyOn(document.body, 'scrollHeight', 'get').mockReturnValue(hasScroll ? 900 : 500)
}
async function renderShare(keyword = '') {
return renderWithProviders(SubscribeShareView, {
props: { keyword },
global: {
stubs: {
LoadingBanner: LoadingBannerStub,
NoDataFound: NoDataFoundStub,
ProgressiveCardGrid: ProgressiveCardGridStub,
SubscribeShareCard: SubscribeShareCardStub,
VInfiniteScroll: InfiniteScrollStub,
VPageContentTitle: PageContentTitleStub,
},
},
})
}
describe('SubscribeShareView', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
setHasScroll(true)
})
it('loads the default list with the exact query and stable share IDs', async () => {
const share = createSubscribeShare({ id: 3101, share_title: '默认分享卡片' })
const requests: URL[] = []
server.use(
subscribeSharesHandler([share], 200, url => {
requests.push(url)
}),
)
await renderShare()
expect(await screen.findByText('默认分享卡片')).toBeInTheDocument()
expect(requests).toHaveLength(1)
expect(requests[0].searchParams.get('page')).toBe('1')
expect(requests[0].searchParams.get('count')).toBe('30')
expect(requests[0].searchParams.get('name') ?? '').toBe('')
expect(requests[0].searchParams.get('sort_type')).toBe('time')
expect(requests[0].searchParams.get('genre_id')).toBeNull()
expect(requests[0].searchParams.get('min_rating')).toBeNull()
expect(requests[0].searchParams.get('max_rating')).toBeNull()
expect(screen.getByRole('status', { name: '订阅分享渐进网格键' })).toHaveTextContent('3101')
})
it('resets to page one with exact sort, genre and rating filters', async () => {
const requests: URL[] = []
server.use(
http.get(subscribeApiUrls.shares, ({ request }) => {
const url = new URL(request.url)
requests.push(url)
const shareTitle = url.searchParams.has('min_rating')
? '高分分享结果'
: url.searchParams.has('genre_id')
? '动作分享结果'
: url.searchParams.get('sort_type') === 'count'
? '热门分享结果'
: '默认分享结果'
return HttpResponse.json([createSubscribeShare({ share_title: shareTitle })])
}),
)
const user = userEvent.setup()
await renderShare()
expect(await screen.findByText('默认分享结果')).toBeInTheDocument()
await user.click(screen.getByText('热门'))
expect(await screen.findByText('热门分享结果')).toBeInTheDocument()
expect(screen.queryByText('默认分享结果')).not.toBeInTheDocument()
await user.click(screen.getByText('动作'))
expect(await screen.findByText('动作分享结果')).toBeInTheDocument()
expect(screen.queryByText('热门分享结果')).not.toBeInTheDocument()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}'.repeat(6))
expect(await screen.findByText('高分分享结果')).toBeInTheDocument()
expect(screen.queryByText('动作分享结果')).not.toBeInTheDocument()
expect(requests.length).toBeGreaterThanOrEqual(4)
expect(requests.slice(1).every(url => url.searchParams.get('page') === '1')).toBe(true)
expect(requests[1].searchParams.get('sort_type')).toBe('count')
expect(requests[2].searchParams.get('genre_id')).toBe('28')
expect(requests.at(-1)?.searchParams.get('min_rating')).toBe('6')
})
it('appends later pages and stops when a page is empty', async () => {
const first = createSubscribeShare({ share_title: '分享第一页' })
const second = createSubscribeShare({ share_title: '分享第二页' })
const requestedPages: string[] = []
server.use(
http.get(subscribeApiUrls.shares, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first])
if (page === '2') return HttpResponse.json([second])
return HttpResponse.json([])
}),
)
const user = userEvent.setup()
await renderShare()
expect(await screen.findByText('分享第一页')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '触发订阅分享加载' }))
expect(await screen.findByText('分享第二页')).toBeInTheDocument()
expect(screen.getByText('分享第一页')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '触发订阅分享加载' }))
await waitFor(() => expect(requestedPages).toEqual(['1', '2', '3']))
expect(screen.getByText('分享第一页')).toBeInTheDocument()
expect(screen.getByText('分享第二页')).toBeInTheDocument()
})
it('loads consecutive pages until an underfilled viewport becomes scrollable', async () => {
const first = createSubscribeShare({ share_title: '未满屏分享第一页' })
const second = createSubscribeShare({ share_title: '未满屏分享第二页' })
const requestedPages: string[] = []
const scrollHeight = vi.spyOn(document.body, 'scrollHeight', 'get').mockImplementation(() =>
requestedPages.length >= 2 ? 900 : 500,
)
server.use(
http.get(subscribeApiUrls.shares, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
return HttpResponse.json(page === '1' ? [first] : [second])
}),
)
await renderShare()
expect(await screen.findByText('未满屏分享第一页')).toBeInTheDocument()
expect(await screen.findByText('未满屏分享第二页')).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '2'])
scrollHeight.mockRestore()
})
it('shows the keyword-specific no-data state for an empty first page', async () => {
server.use(subscribeSharesHandler([]))
await renderShare('不存在的分享')
expect(await screen.findByRole('heading', { name: '搜索:不存在的分享' })).toBeInTheDocument()
expect(await screen.findByRole('region', { name: '订阅分享空态' })).toBeInTheDocument()
expect(screen.queryByText('正在加载订阅分享')).not.toBeInTheDocument()
})
it('deduplicates concurrent load events while the request is pending', async () => {
const gate = createDeferred()
const requests: URL[] = []
server.use(
subscribeSharesHandler([createSubscribeShare({ share_title: '并发分享结果' })], 200, async url => {
requests.push(url)
await gate.promise
}),
)
const user = userEvent.setup()
await renderShare()
await waitFor(() => expect(requests).toHaveLength(1))
await user.click(screen.getByRole('button', { name: '触发订阅分享加载' }))
await user.click(screen.getByRole('button', { name: '触发订阅分享加载' }))
expect(requests).toHaveLength(1)
expect(screen.getByRole('status', { name: '订阅分享无限列表状态' })).toHaveTextContent('loading')
gate.resolve()
expect(await screen.findByText('并发分享结果')).toBeInTheDocument()
})
it('reloads page one when the keyword prop changes', async () => {
const requests: URL[] = []
server.use(
http.get(subscribeApiUrls.shares, ({ request }) => {
const url = new URL(request.url)
requests.push(url)
const keyword = url.searchParams.get('name') ?? ''
return HttpResponse.json([
createSubscribeShare({ share_title: keyword === '新关键字' ? '新关键字分享' : '旧关键字分享' }),
])
}),
)
const view = await renderShare('旧关键字')
expect(await screen.findByText('旧关键字分享')).toBeInTheDocument()
await view.rerender({ keyword: '新关键字' })
expect(await screen.findByText('新关键字分享')).toBeInTheDocument()
expect(screen.queryByText('旧关键字分享')).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: '搜索:新关键字' })).toBeInTheDocument()
expect(requests.map(url => url.searchParams.get('name'))).toEqual(['旧关键字', '新关键字'])
expect(requests.every(url => url.searchParams.get('page') === '1')).toBe(true)
})
it('removes only the share whose card emitted delete', async () => {
const first = createSubscribeShare({ id: 4101, share_title: '待删除分享' })
const second = createSubscribeShare({ id: 4102, share_title: '保留分享' })
server.use(subscribeSharesHandler([first, second]))
const user = userEvent.setup()
await renderShare()
expect(await screen.findByText('待删除分享')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '删除分享 4101' }))
expect(screen.queryByText('待删除分享')).not.toBeInTheDocument()
expect(screen.getByText('保留分享')).toBeInTheDocument()
})
it('recovers from an initial HTTP failure by retrying page one', async () => {
const requestedPages: string[] = []
server.use(
http.get(subscribeApiUrls.shares, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (requestedPages.length === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([createSubscribeShare({ share_title: '分享首载重试成功' })])
}),
)
const user = userEvent.setup()
await renderShare()
const retry = await screen.findByRole('button', { name: '重试' })
expect(screen.getByRole('alert')).toBeInTheDocument()
await user.click(retry)
expect(await screen.findByText('分享首载重试成功')).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '1'])
})
it('keeps existing shares and retries the failed later page', async () => {
const first = createSubscribeShare({ share_title: '分享保留第一页' })
const second = createSubscribeShare({ share_title: '分享第二页重试成功' })
const requestedPages: string[] = []
let pageTwoAttempts = 0
server.use(
http.get(subscribeApiUrls.shares, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first])
pageTwoAttempts += 1
if (pageTwoAttempts === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([second])
}),
)
const user = userEvent.setup()
await renderShare()
expect(await screen.findByText('分享保留第一页')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '触发订阅分享加载' }))
const retry = await screen.findByRole('button', { name: '重试' })
expect(screen.getByText('分享保留第一页')).toBeInTheDocument()
await user.click(retry)
expect(await screen.findByText('分享第二页重试成功')).toBeInTheDocument()
expect(screen.getByText('分享保留第一页')).toBeInTheDocument()
expect(requestedPages).toEqual(['1', '2', '2'])
})
it('ignores an obsolete response when keyword resets a pending request', async () => {
const gate = createDeferred()
const requests: URL[] = []
const staleResponse = vi.fn()
server.use(
http.get(subscribeApiUrls.shares, async ({ request }) => {
const url = new URL(request.url)
requests.push(url)
if (url.searchParams.get('name') === '旧关键字') {
await gate.promise
staleResponse()
return HttpResponse.json([createSubscribeShare({ share_title: '过期关键字分享' })])
}
return HttpResponse.json([createSubscribeShare({ share_title: '新关键字实时分享' })])
}),
)
const view = await renderShare('旧关键字')
await waitFor(() => expect(requests).toHaveLength(1))
await view.rerender({ keyword: '新关键字' })
expect(await screen.findByText('新关键字实时分享')).toBeInTheDocument()
expect(requests.filter(url => url.searchParams.get('name') === '新关键字')).toHaveLength(1)
expect(requests.at(-1)?.searchParams.get('page')).toBe('1')
gate.resolve()
await waitFor(() => expect(staleResponse).toHaveBeenCalledOnce())
await flushPromises()
await flushPromises()
expect(screen.getByText('新关键字实时分享')).toBeInTheDocument()
expect(screen.queryByText('过期关键字分享')).not.toBeInTheDocument()
})
})

View File

@@ -4,11 +4,14 @@ import type {
MediaInfo,
Site,
Subscribe,
SubscribeShare,
SubscribeShareStatistics,
TransferDirectoryConf,
} from '@/api/types'
import { createMediaInfo } from './media'
let subscribeSeed = 1000
let subscribeShareSeed = 2000
let siteSeed = 100
/** 构造满足前端订阅契约的最小记录。 */
@@ -35,6 +38,42 @@ export function createSubscribe(overrides: Partial<Subscribe> = {}): Subscribe {
}
}
/** 构造订阅分享列表、卡片与复用弹窗共同使用的稳定记录。 */
export function createSubscribeShare(overrides: Partial<SubscribeShare> = {}): SubscribeShare {
subscribeShareSeed += 1
return {
backdrop: `https://images.example.com/share-backdrop-${subscribeShareSeed}.jpg`,
count: 3,
date: '2026-07-17 12:00:00',
id: subscribeShareSeed,
name: `分享媒体 ${subscribeShareSeed}`,
poster: `https://images.example.com/share-poster-${subscribeShareSeed}.jpg`,
share_comment: `分享说明 ${subscribeShareSeed}`,
share_title: `分享标题 ${subscribeShareSeed}`,
share_uid: `share-user-${subscribeShareSeed}`,
share_user: `分享用户 ${subscribeShareSeed}`,
subscribe_id: subscribeShareSeed + 1000,
tmdbid: subscribeShareSeed,
type: '电影',
vote: 8.2,
year: '2026',
...overrides,
}
}
/** 构造分享排行榜使用的稳定聚合记录。 */
export function createSubscribeShareStatistics(
overrides: Partial<SubscribeShareStatistics> = {},
): SubscribeShareStatistics {
subscribeShareSeed += 1
return {
share_count: 2,
share_user: `统计用户 ${subscribeShareSeed}`,
total_reuse_count: 5,
...overrides,
}
}
/** 构造电影媒体信息。 */
export function createSubscribeMovie(overrides: Partial<MediaInfo> = {}): MediaInfo {
return createMediaInfo({ type: '电影', ...overrides })

View File

@@ -1,4 +1,13 @@
import type { DownloaderConf, FilterRuleGroup, Site, Subscribe, TransferDirectoryConf } from '@/api/types'
import type {
DownloaderConf,
FilterRuleGroup,
MediaInfo,
Site,
Subscribe,
SubscribeShare,
SubscribeShareStatistics,
TransferDirectoryConf,
} from '@/api/types'
import { HttpResponse, http, type JsonBodyType, type RequestHandler } from 'msw'
const API_BASE_URL = 'http://localhost/api/v1/'
@@ -28,12 +37,20 @@ export const subscribeApiUrls = {
filesById: (id: number) => new URL(`subscribe/files/${id}`, API_BASE_URL).href,
historyById: (id: number) => new URL(`subscribe/history/${id}`, API_BASE_URL).href,
historyByType: (type: SubscribeMediaType) => new URL(`subscribe/history/${type}`, API_BASE_URL).href,
follow: new URL('subscribe/follow', API_BASE_URL).href,
followSubscribers: new URL('system/setting/public/FollowSubscribers', API_BASE_URL).href,
fork: new URL('subscribe/fork', 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,
popular: new URL('subscribe/popular', API_BASE_URL).href,
resetById: (id: number) => new URL(`subscribe/reset/${id}`, API_BASE_URL).href,
searchById: (id: number) => new URL(`subscribe/search/${id}`, API_BASE_URL).href,
share: new URL('subscribe/share', API_BASE_URL).href,
shareById: (id: number) => new URL(`subscribe/share/${id}`, API_BASE_URL).href,
shareStatistics: new URL('subscribe/share/statistics', API_BASE_URL).href,
shares: new URL('subscribe/shares', 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,
@@ -54,6 +71,108 @@ export function subscribeListHandler(
})
}
export function popularSubscribesHandler(
response: MediaInfo[] = [],
status = 200,
onRequest: (url: URL) => void | Promise<void> = () => {},
) {
return http.get(subscribeApiUrls.popular, async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse(response as unknown as JsonBodyType, status)
})
}
export function subscribeSharesHandler(
response: SubscribeShare[] = [],
status = 200,
onRequest: (url: URL) => void | Promise<void> = () => {},
) {
return http.get(subscribeApiUrls.shares, async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse(response as unknown as JsonBodyType, status)
})
}
export function shareSubscribeHandler(
response: SubscribeMutationResponse = { success: true },
status = 200,
onShare: (payload: SubscribeShare) => void | Promise<void> = () => {},
) {
return http.post(subscribeApiUrls.share, async ({ request }) => {
const payload = (await request.json()) as SubscribeShare
await onShare(payload)
return jsonResponse(response, status)
})
}
export function forkSubscribeHandler(
response: SubscribeMutationResponse = { data: { id: 1 }, success: true },
status = 200,
onFork: (payload: SubscribeShare) => void | Promise<void> = () => {},
) {
return http.post(subscribeApiUrls.fork, async ({ request }) => {
const payload = (await request.json()) as SubscribeShare
await onFork(payload)
return jsonResponse(response, status)
})
}
export function followSubscribersSettingHandler(
users: string[] = [],
status = 200,
onRequest: (url: URL) => void | Promise<void> = () => {},
) {
return http.get(subscribeApiUrls.followSubscribers, async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse({ data: { value: users }, success: status < 400 }, status)
})
}
export function followSubscriberHandler(
response: SubscribeMutationResponse = { success: true },
status = 200,
onRequest: (url: URL) => void | Promise<void> = () => {},
) {
return http.post(subscribeApiUrls.follow, async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse(response, status)
})
}
export function unfollowSubscriberHandler(
response: SubscribeMutationResponse = { success: true },
status = 200,
onRequest: (url: URL) => void | Promise<void> = () => {},
) {
return http.delete(subscribeApiUrls.follow, async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse(response, status)
})
}
export function deleteSubscribeShareHandler(
id: number,
response: SubscribeMutationResponse = { success: true },
status = 200,
onRequest: (url: URL) => void | Promise<void> = () => {},
) {
return http.delete(subscribeApiUrls.shareById(id), async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse(response, status)
})
}
export function subscribeShareStatisticsHandler(
response: SubscribeShareStatistics[] = [],
status = 200,
onRequest: (url: URL) => void | Promise<void> = () => {},
) {
return http.get(subscribeApiUrls.shareStatistics, async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse(response as unknown as JsonBodyType, status)
})
}
export function subscribeFilesHandler(
id: number,
response: JsonBodyType = { episodes: {}, subscribe: null },

View File

@@ -280,12 +280,18 @@ export default defineConfig(({ mode }) => ({
'src/views/dashboard/MediaRecommend.vue',
'src/views/subscribe/FullCalendarView.vue',
'src/views/subscribe/SubscribeListView.vue',
'src/views/subscribe/SubscribePopularView.vue',
'src/views/subscribe/SubscribeShareView.vue',
'src/composables/useMediaSubscribe.ts',
'src/components/cards/SubscribeCard.vue',
'src/components/cards/SubscribeShareCard.vue',
'src/components/dialog/ForkSubscribeDialog.vue',
'src/components/dialog/SubscribeEditDialog.vue',
'src/components/dialog/SubscribeFilesDialog.vue',
'src/components/dialog/SubscribeHistoryDialog.vue',
'src/components/dialog/SubscribeSeasonDialog.vue',
'src/components/dialog/SubscribeShareDialog.vue',
'src/components/dialog/SubscribeShareStatisticsDialog.vue',
],
provider: 'v8',
reporter: ['text', 'json-summary', 'html'],
@@ -301,6 +307,18 @@ export default defineConfig(({ mode }) => ({
lines: 80,
statements: 80,
},
'src/components/cards/SubscribeShareCard.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/components/dialog/ForkSubscribeDialog.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/components/dialog/SubscribeEditDialog.vue': {
branches: 75,
functions: 80,
@@ -325,6 +343,18 @@ export default defineConfig(({ mode }) => ({
lines: 90,
statements: 90,
},
'src/components/dialog/SubscribeShareDialog.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/components/dialog/SubscribeShareStatisticsDialog.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/composables/useMediaSubscribe.ts': {
branches: 75,
functions: 80,
@@ -379,6 +409,18 @@ export default defineConfig(({ mode }) => ({
lines: 80,
statements: 80,
},
'src/views/subscribe/SubscribePopularView.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/views/subscribe/SubscribeShareView.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
},
},
},