mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-12 09:05:28 +08:00
test(subscribe): cover popular and share workflows (#545)
This commit is contained in:
@@ -114,6 +114,8 @@ export interface SubscribeShare {
|
||||
tmdbid?: number
|
||||
// 豆瓣ID
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: number
|
||||
// 季号
|
||||
season?: number
|
||||
// 海报
|
||||
|
||||
@@ -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>
|
||||
|
||||
180
src/components/cards/__tests__/SubscribeShareCard.spec.ts
Normal file
180
src/components/cards/__tests__/SubscribeShareCard.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
395
src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts
Normal file
395
src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
170
src/components/dialog/__tests__/SubscribeShareDialog.spec.ts
Normal file
170
src/components/dialog/__tests__/SubscribeShareDialog.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')"
|
||||
|
||||
@@ -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')"
|
||||
|
||||
401
src/views/subscribe/__tests__/SubscribePopularView.spec.ts
Normal file
401
src/views/subscribe/__tests__/SubscribePopularView.spec.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
441
src/views/subscribe/__tests__/SubscribeShareView.spec.ts
Normal file
441
src/views/subscribe/__tests__/SubscribeShareView.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user