test(subscribe): cover card actions (#539)

This commit is contained in:
InfinityPacer
2026-07-17 12:57:28 +08:00
committed by GitHub
parent 00feeb86ca
commit 807711cae8
4 changed files with 476 additions and 0 deletions
+5
View File
@@ -173,6 +173,7 @@ async function removeSubscribe() {
emit('remove')
}
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
@@ -184,7 +185,9 @@ async function searchSubscribe() {
// 提示
if (result.success) $toast.success(`${props.media?.name} 提交搜索请求成功!`)
else $toast.error(t('subscribe.requestFailed'))
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
@@ -211,6 +214,7 @@ async function toggleSubscribeStatus(state: 'R' | 'S') {
$toast.error(t('subscribe.toggleFailed', { action, message: result.message }))
}
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
@@ -233,6 +237,7 @@ async function resetSubscribe() {
emit('save')
} else $toast.error(t('subscribe.resetFailed', { name: props.media?.name, message: result.message }))
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
@@ -0,0 +1,438 @@
import { formatDateDifference } from '@/@core/utils/formatters'
import type { Subscribe } from '@/api/types'
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { createSubscribe } from '@tests/support/factories/subscribe'
import {
deleteSubscribeByIdHandler,
resetSubscribeByIdHandler,
searchSubscribeByIdHandler,
updateSubscribeStatusHandler,
} from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
confirm: vi.fn(),
openSharedDialog: vi.fn(),
routerPush: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => mocks.confirm,
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/router', () => ({
default: { push: (...args: unknown[]) => mocks.routerPush(...args) },
}))
function setViewport(width: number) {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width, writable: true })
window.dispatchEvent(new Event('resize'))
}
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(
mediaOverrides: Partial<Subscribe> = {},
props: Partial<{ batchMode: boolean; selected: boolean; sortable: boolean }> = {},
globalImageCache = false,
) {
const media = createSubscribe({
backdrop: 'https://images.example.com/backdrop.jpg',
id: 2501,
last_update: '2026-07-16 12:00:00',
name: '卡片测试媒体',
poster: 'https://images.example.com/poster.jpg',
...mediaOverrides,
})
const result = await renderWithProviders(SubscribeCard, {
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: globalImageCache },
initialized: true,
loading: false,
},
},
props: { media, ...props },
})
return { ...result, media }
}
function getMenuButton(container: Element) {
const selector = window.innerWidth < 600 ? '.subscribe-card-mobile-menu' : '.absolute.top-1.right-4 .v-btn'
const button = container.querySelector<HTMLButtonElement>(selector)
expect(button).not.toBeNull()
return button as HTMLButtonElement
}
async function openMenu(container: Element) {
await fireEvent.click(getMenuButton(container))
}
async function chooseMenuItem(container: Element, label: string) {
await openMenu(container)
await fireEvent.click(await screen.findByText(label))
}
function getDialogCall(index = 0) {
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
unknown,
Record<string, unknown>,
Record<string, () => void>,
Record<string, unknown>,
]
return { events, options, props }
}
describe('SubscribeCard display and progress', () => {
beforeEach(() => {
setViewport(1024)
observeElementsImmediately()
mocks.confirm.mockResolvedValue(true)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('renders stable movie metadata and omits episode progress without a total', async () => {
const { container, media } = await renderCard({ total_episode: undefined, type: '电影', year: '2025' }, {}, true)
expect(screen.getByText(media.name)).toBeInTheDocument()
expect(screen.getByText('2025')).toBeInTheDocument()
expect(screen.getByText(media.username)).toHaveAttribute('title', media.username)
const image = container.querySelector<HTMLImageElement>('img')
expect(image).not.toBeNull()
expect((image as HTMLImageElement).src).toContain('system/cache/image?url=')
expect((image as HTMLImageElement).src).toContain(encodeURIComponent(media.backdrop || ''))
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument()
})
it.each([
['regular progress', 10, 4, '6 / 10', '60'],
['negative missing episodes', 10, -2, '10 / 10', '100'],
['missing episodes above the total', 10, 12, '0 / 10', null],
['zero total', 0, 0, null, null],
])('normalizes %s', async (_case, totalEpisode, lackEpisode, expectedText, expectedProgress) => {
await renderCard({ lack_episode: lackEpisode, season: 2, total_episode: totalEpisode, type: '电视剧' })
if (expectedText) expect(screen.getByText(expectedText)).toBeInTheDocument()
else expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument()
if (expectedProgress) expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', expectedProgress)
else expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(screen.getByText(/卡片测试媒体 S02/)).toBeInTheDocument()
})
it.each([
['boolean flag with tv type', true, false, 3, 'tv', 30, true, false],
['numeric flags with negative completed episodes', 1, 1, -2, '电视剧', 0, true, true],
['string flags with completed episodes above the total', '1', '1', 12, '电影', 100, true, true],
['disabled flag', false, true, 3, '电视剧', 80, false, false],
])(
'normalizes %s for wash progress and badges',
async (_case, bestVersion, bestVersionFull, completedEpisode, type, expectedProgress, expectedWash, expectedFull) => {
const { container } = await renderCard({
best_version: bestVersion,
best_version_full: bestVersionFull,
completed_episode: completedEpisode,
lack_episode: 2,
total_episode: 10,
type,
})
const image = container.querySelector<HTMLImageElement>('img')
expect(image).not.toBeNull()
await fireEvent.load(image as HTMLImageElement)
const progress = screen.getByRole('progressbar')
expect(progress).toHaveAttribute('aria-valuenow', String(expectedProgress))
expect(progress.querySelector('.v-progress-linear__buffer')).toHaveStyle({ width: expectedWash ? '80%' : '0%' })
expect(Boolean(container.querySelector('.best-version-badge'))).toBe(expectedWash)
expect(Boolean(container.querySelector('.best-version-badge-full'))).toBe(expectedFull)
},
)
it('keeps mobile wash progress compact while preserving P, S, and R metadata', async () => {
setViewport(480)
const { media, rerender } = await renderCard({
best_version: true,
completed_episode: 3,
lack_episode: 2,
state: 'P',
total_episode: 10,
type: '电视剧',
})
const lastUpdateText = formatDateDifference(media.last_update)
expect(screen.getByLabelText('待定中')).toBeInTheDocument()
expect(screen.getByText('8 / 10')).toBeInTheDocument()
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '30')
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
expect(document.querySelector('.subscribe-card-mobile-menu')).toBeInTheDocument()
await rerender({ media: { ...media, state: 'S' } })
expect(screen.getByLabelText('已暂停')).toBeInTheDocument()
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
await rerender({ media: { ...media, state: 'R' } })
expect(screen.getByLabelText('订阅中')).toBeInTheDocument()
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
})
it('synchronizes desktop P, S, and R state from updated media props', async () => {
const { container, media, rerender } = await renderCard({ state: 'P' })
const lastUpdateText = formatDateDifference(media.last_update)
expect(screen.getByText('待定中')).toBeInTheDocument()
expect(screen.queryByText(lastUpdateText)).not.toBeInTheDocument()
await rerender({ media: { ...media, state: 'S' } })
expect(screen.getByText('已暂停')).toBeInTheDocument()
expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-paused')
await rerender({ media: { ...media, state: 'R' } })
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
expect(screen.queryByText('已暂停')).not.toBeInTheDocument()
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
})
})
describe('SubscribeCard interaction boundaries', () => {
beforeEach(() => {
setViewport(1024)
observeElementsImmediately()
mocks.confirm.mockResolvedValue(true)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('routes normal, batch, selected, and sortable card clicks without overlap', async () => {
const { container, emitted, media, rerender } = await renderCard()
const card = container.querySelector('.subscribe-card') as HTMLElement
await fireEvent.click(card)
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
await rerender({ batchMode: true, media, selected: true })
await fireEvent.click(card)
expect(emitted('select')).toHaveLength(1)
expect(container.querySelector('.subscribe-card-shell')).toHaveClass('subscribe-card-shell--selected')
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
expect(container.querySelector('.absolute.top-1.right-4 .v-btn')).toBeInTheDocument()
await rerender({ batchMode: true, media, selected: true, sortable: true })
await fireEvent.click(card)
expect(emitted('select')).toHaveLength(1)
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
expect(container.querySelector('.absolute.top-1.right-4 .v-btn')).not.toBeInTheDocument()
})
it('opens page-selected editing and forwards only save and remove events', async () => {
const { emitted, media } = await renderCard({ page_open: true })
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const dialog = getDialogCall()
expect(dialog.props).toEqual({ subid: media.id })
expect(dialog.options).toEqual({ closeOn: ['close', 'save', 'remove'] })
dialog.events.save()
dialog.events.remove()
expect(emitted('save')).toHaveLength(1)
expect(emitted('remove')).toHaveLength(1)
})
it('passes exact file and TV share data while keeping compatibility TV values unshared', async () => {
const { container, media, rerender } = await renderCard({ season: 1, total_episode: 12, type: '电视剧' })
await chooseMenuItem(container, '分享')
expect(getDialogCall().props).toEqual({ sub: media })
expect(getDialogCall().options).toEqual({ closeOn: ['close'] })
await chooseMenuItem(container, '文件统计')
expect(getDialogCall(1).props).toEqual({ subid: media.id })
expect(getDialogCall(1).options).toEqual({ closeOn: ['close'] })
await rerender({ media: { ...media, type: 'tv' } })
await openMenu(container)
expect(screen.queryByText('分享')).not.toBeInTheDocument()
})
it.each([
['TMDB before all fallbacks', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 11 }, 'tmdb:11'],
['Douban before Bangumi', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 0 }, 'douban:22'],
['Bangumi before custom', { bangumiid: '33', doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'bangumi:33'],
['custom media ID last', { bangumiid: undefined, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'custom:44'],
])('routes media details with %s', async (_case, identifiers, expectedMediaId) => {
const { container, media } = await renderCard(identifiers)
await chooseMenuItem(container, '媒体详情')
expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/media',
query: {
mediaid: expectedMediaId,
title: media.name,
type: media.type,
year: media.year,
},
})
})
})
describe('SubscribeCard item operations', () => {
beforeEach(() => {
setViewport(1024)
observeElementsImmediately()
mocks.confirm.mockResolvedValue(true)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it.each([
['success', 200, { success: true }, 'success', '卡片测试媒体 提交搜索请求成功!'],
['business failure', 200, { message: 'rejected', success: false }, 'error', '请求失败,请稍后重试'],
['HTTP failure', 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'],
] as const)('reports search %s through the exact endpoint', async (_case, status, response, toastType, message) => {
const requested = vi.fn()
const { container, media } = await renderCard()
server.use(searchSubscribeByIdHandler(media.id, response, status, requested))
await chooseMenuItem(container, '搜索')
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
const toast = toastType === 'success' ? mocks.toastSuccess : mocks.toastError
await waitFor(() => expect(toast).toHaveBeenCalledWith(message))
})
it('pauses and enables only after confirmed successful status responses', async () => {
const requested: URL[] = []
const { container, emitted, media } = await renderCard({ state: 'R' })
server.use(
updateSubscribeStatusHandler(media.id, { success: true }, 200, url => {
requested.push(url)
}),
)
await chooseMenuItem(container, '暂停')
await waitFor(() => expect(requested).toHaveLength(1))
expect(requested[0].searchParams.get('state')).toBe('S')
expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-paused')
expect(mocks.toastSuccess).toHaveBeenCalledWith(`${media.name} 已暂停!`)
await chooseMenuItem(container, '启用')
await waitFor(() => expect(requested).toHaveLength(2))
expect(requested[1].searchParams.get('state')).toBe('R')
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
expect(mocks.toastSuccess).toHaveBeenCalledWith(`${media.name} 已启用!`)
expect(emitted('save')).toHaveLength(2)
})
it.each([
['confirmation cancellation', false, 200, { success: true }, null],
['business failure', true, 200, { message: 'rejected', success: false }, '暂停失败:rejected'],
['HTTP failure', true, 500, { message: 'server down', success: false }, '请求失败,请稍后重试'],
] as const)(
'keeps status unchanged after %s',
async (_case, confirmed, status, response, expectedError) => {
const requested = vi.fn()
mocks.confirm.mockResolvedValue(confirmed)
const { container, emitted, media } = await renderCard({ state: 'R' })
server.use(updateSubscribeStatusHandler(media.id, response, status, requested))
await chooseMenuItem(container, '暂停')
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
else expect(requested).not.toHaveBeenCalled()
if (expectedError) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedError))
else expect(mocks.toastError).not.toHaveBeenCalled()
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
expect(emitted('save') ?? []).toHaveLength(0)
},
)
it.each([
['success', true, 200, { success: true }, 'success', '卡片测试媒体 重置成功!'],
['confirmation cancellation', false, 200, { success: true }, null, null],
['business failure', true, 200, { message: 'rejected', success: false }, 'error', '卡片测试媒体 重置失败:rejected'],
['HTTP failure', true, 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'],
] as const)(
'handles reset %s without speculative state',
async (_case, confirmed, status, response, toastType, message) => {
const requested = vi.fn()
mocks.confirm.mockResolvedValue(confirmed)
const { container, emitted, media } = await renderCard({ state: 'S' })
server.use(resetSubscribeByIdHandler(media.id, response, status, requested))
await chooseMenuItem(container, '重置')
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
else expect(requested).not.toHaveBeenCalled()
if (toastType && message) {
const toast = toastType === 'success' ? mocks.toastSuccess : mocks.toastError
await waitFor(() => expect(toast).toHaveBeenCalledWith(message))
} else {
expect(mocks.toastSuccess).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
}
if (_case === 'success') {
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
expect(emitted('save')).toHaveLength(1)
} else {
expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-paused')
expect(emitted('save') ?? []).toHaveLength(0)
}
},
)
it.each([
['success', 200, { success: true }, true, null],
['HTTP failure', 500, { message: 'server down', success: false }, false, '请求失败,请稍后重试'],
] as const)('handles delete %s without a synthetic business-failure branch', async (_case, status, response, removed, error) => {
const requested = vi.fn()
const { container, emitted, media } = await renderCard()
server.use(deleteSubscribeByIdHandler(media.id, response, status, requested))
await chooseMenuItem(container, '取消订阅')
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(emitted('remove') ?? []).toHaveLength(removed ? 1 : 0)
if (error) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(error))
else expect(mocks.toastError).not.toHaveBeenCalled()
})
})